diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..a14aed7d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text eol=lf +*.cmd text eol=crlf diff --git a/.github/stale.yml b/.github/stale.yml index 123d40f6..e1378fdb 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,19 +1,19 @@ -# Number of days of inactivity before an issue becomes stale -daysUntilStale: 60 -# Number of days of inactivity before a stale issue is closed -daysUntilClose: 7 -# Issues with these labels will never be considered stale -exemptLabels: - - pinned - - security - - enhancement - - bug -# Label to use when marking an issue as stale -staleLabel: wontfix -# Comment to post when marking an issue as stale. Set to `false` to disable -markComment: > - 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 - for your contributions. -# Comment to post when closing a stale issue. Set to `false` to disable +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 60 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 +# Issues with these labels will never be considered stale +exemptLabels: + - pinned + - security + - enhancement + - bug +# Label to use when marking an issue as stale +staleLabel: wontfix +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + 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 + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable closeComment: false \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fe7da42..03a779a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,272 +1,269 @@ -name: ci -permissions: - contents: read - -on: - push: - pull_request: - -env: - JAVA_VERSION: 21 - DOCKER_BUILD_SUMMARY: false - -jobs: - build: - if: github.event_name != 'pull_request' || github.actor != 'renovate[bot]' # renovate already triggers the build on pushes - - strategy: - matrix: - os: [ "ubuntu-latest", "ubuntu-22.04-arm", "windows-latest" ] - database: [ "h2", "postgresql", "mysql", "mariadb" ] - - runs-on: ${{ matrix.os }} - steps: - # Checkout - - name: Configure git to checkout as-is - run: git config --global core.autocrlf false - - - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - fetch-depth: 0 - - # Setup - - name: Set up GraalVM - uses: graalvm/setup-graalvm@01ed653ac833fe80569f1ef9f25585ba2811baab # v1 - with: - java-version: ${{ env.JAVA_VERSION }} - distribution: "graalvm" - cache: "maven" - - - name: Install Playwright dependencies - run: sudo apt-get install -y libgbm1 - if: matrix.os != 'windows-latest' - - # Build & Test - - name: Build with Maven - run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipTests=${{ matrix.os == 'windows-latest' && matrix.database != 'h2' }} - - # Build pages - - name: Copy generated markdown documentation to /documentation - run: mkdir documentation && cp ./commafeed-server/target/quarkus-generated-doc/config/commafeed-server.md ./documentation/README.md - - - name: Generate pages - uses: wranders/markdown-to-pages-action@8d8a750832932ac785f5424c8c5543aa0b26bb9a # v1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - out_path: target/pages - files: |- - README.md - documentation/README.md - - # Upload artifacts - - name: Upload cross-platform app - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4 - if: matrix.os == 'ubuntu-latest' # we only need to upload the cross-platform artifact once per database - with: - name: commafeed-${{ matrix.database }}-jvm - path: commafeed-server/target/commafeed-*.zip - - - name: Upload native executable - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4 - with: - name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }} - path: commafeed-server/target/commafeed-*-runner* - - - name: Upload pages - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 - if: matrix.os == 'ubuntu-latest' && matrix.database == 'h2' # we only need to upload the pages once - with: - path: target/pages - - docker: - runs-on: ubuntu-latest - needs: build - env: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - - strategy: - matrix: - database: [ "h2", "postgresql", "mysql", "mariadb" ] - - steps: - # Checkout - - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - fetch-depth: 0 - - # Setup - - name: Set up QEMU - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3 - - - name: Install required packages - run: sudo apt-get install -y rename unzip - - # Prepare artifacts - - name: Download artifacts - uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4 - with: - pattern: commafeed-${{ matrix.database }}-* - path: ./artifacts - merge-multiple: true - - - name: Set the exec flag on the native executables - run: chmod +x artifacts/*-runner - - - name: Rename native executables to match buildx TARGETARCH - run: | - rename 's/x86_64/amd64/g' artifacts/* - rename 's/aarch_64/arm64/g' artifacts/* - - - name: Unzip jvm package - run: | - unzip artifacts/*-jvm.zip -d artifacts/extracted-jvm-package - rename 's/commafeed-.*/quarkus-app/g' artifacts/extracted-jvm-package/* - - # Docker - - name: Login to Container Registry - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3 - if: ${{ env.DOCKERHUB_USERNAME != '' }} - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - ## build but don't push for PRs and renovate - - name: Docker build - native - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 - with: - context: . - file: commafeed-server/src/main/docker/Dockerfile.native - push: false - platforms: linux/amd64,linux/arm64/v8 - - - name: Docker build - jvm - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 - with: - context: . - file: commafeed-server/src/main/docker/Dockerfile.jvm - push: false - platforms: linux/amd64,linux/arm64/v8 - - ## build and push tag - - name: Docker build and push tag - native - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 - if: ${{ github.ref_type == 'tag' }} - with: - context: . - file: commafeed-server/src/main/docker/Dockerfile.native - push: ${{ env.DOCKERHUB_USERNAME != '' }} - platforms: linux/amd64,linux/arm64/v8 - tags: | - athou/commafeed:latest-${{ matrix.database }} - athou/commafeed:${{ github.ref_name }}-${{ matrix.database }} - - - name: Docker build and push tag - jvm - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 - if: ${{ github.ref_type == 'tag' }} - with: - context: . - file: commafeed-server/src/main/docker/Dockerfile.jvm - push: ${{ env.DOCKERHUB_USERNAME != '' }} - platforms: linux/amd64,linux/arm64/v8 - tags: | - athou/commafeed:latest-${{ matrix.database }}-jvm - athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}-jvm - - ## build and push master - - name: Docker build and push master - native - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 - if: ${{ github.ref_name == 'master' }} - with: - context: . - file: commafeed-server/src/main/docker/Dockerfile.native - push: ${{ env.DOCKERHUB_USERNAME != '' }} - platforms: linux/amd64,linux/arm64/v8 - tags: athou/commafeed:master-${{ matrix.database }} - - - name: Docker build and push master - jvm - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 - if: ${{ github.ref_name == 'master' }} - with: - context: . - file: commafeed-server/src/main/docker/Dockerfile.jvm - push: ${{ env.DOCKERHUB_USERNAME != '' }} - platforms: linux/amd64,linux/arm64/v8 - tags: athou/commafeed:master-${{ matrix.database }}-jvm - - release: - runs-on: ubuntu-latest - needs: - - build - - docker - permissions: - contents: write - if: github.ref_type == 'tag' - - steps: - - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - fetch-depth: 0 - - - name: Download artifacts - uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4 - with: - pattern: commafeed-* - path: ./artifacts - merge-multiple: true - - - name: Set the exec flag on the native executables - run: chmod +x artifacts/*-runner - - - name: Extract Changelog Entry - uses: mindsers/changelog-reader-action@32aa5b4c155d76c94e4ec883a223c947b2f02656 # v2 - id: changelog_reader - with: - version: ${{ github.ref_name }} - - - name: Create GitHub release - uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1 - with: - name: CommaFeed ${{ github.ref_name }} - body: ${{ steps.changelog_reader.outputs.changes }} - artifacts: ./artifacts/* - - - update-dockerhub-description: - runs-on: ubuntu-latest - needs: release - - steps: - - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - fetch-depth: 0 - - - name: Update Docker Hub Description - uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - repository: athou/commafeed - short-description: ${{ github.event.repository.description }} - readme-filepath: commafeed-server/src/main/docker/README.md - - - deploy-pages: - runs-on: ubuntu-latest - needs: release - permissions: - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - - steps: - - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 - id: deployment +name: ci +permissions: + contents: read + +on: + push: + pull_request: + +env: + JAVA_VERSION: 21 + DOCKER_BUILD_SUMMARY: false + +jobs: + build: + if: github.event_name != 'pull_request' || github.actor != 'renovate[bot]' # renovate already triggers the build on pushes + + strategy: + matrix: + os: [ "ubuntu-latest", "ubuntu-22.04-arm", "windows-latest" ] + database: [ "h2", "postgresql", "mysql", "mariadb" ] + + runs-on: ${{ matrix.os }} + steps: + # Checkout + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + fetch-depth: 0 + + # Setup + - name: Set up GraalVM + uses: graalvm/setup-graalvm@01ed653ac833fe80569f1ef9f25585ba2811baab # v1 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: "graalvm" + cache: "maven" + + - name: Install Playwright dependencies + run: sudo apt-get install -y libgbm1 + if: matrix.os != 'windows-latest' + + # Build & Test + - name: Build with Maven + run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipTests=${{ matrix.os == 'windows-latest' && matrix.database != 'h2' }} + + # Build pages + - name: Copy generated markdown documentation to /documentation + run: mkdir documentation && cp ./commafeed-server/target/quarkus-generated-doc/config/commafeed-server.md ./documentation/README.md + + - name: Generate pages + uses: wranders/markdown-to-pages-action@8d8a750832932ac785f5424c8c5543aa0b26bb9a # v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + out_path: target/pages + files: |- + README.md + documentation/README.md + + # Upload artifacts + - name: Upload cross-platform app + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4 + if: matrix.os == 'ubuntu-latest' # we only need to upload the cross-platform artifact once per database + with: + name: commafeed-${{ matrix.database }}-jvm + path: commafeed-server/target/commafeed-*.zip + + - name: Upload native executable + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4 + with: + name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }} + path: commafeed-server/target/commafeed-*-runner* + + - name: Upload pages + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 + if: matrix.os == 'ubuntu-latest' && matrix.database == 'h2' # we only need to upload the pages once + with: + path: target/pages + + docker: + runs-on: ubuntu-latest + needs: build + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + + strategy: + matrix: + database: [ "h2", "postgresql", "mysql", "mariadb" ] + + steps: + # Checkout + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + fetch-depth: 0 + + # Setup + - name: Set up QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3 + + - name: Install required packages + run: sudo apt-get install -y rename unzip + + # Prepare artifacts + - name: Download artifacts + uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4 + with: + pattern: commafeed-${{ matrix.database }}-* + path: ./artifacts + merge-multiple: true + + - name: Set the exec flag on the native executables + run: chmod +x artifacts/*-runner + + - name: Rename native executables to match buildx TARGETARCH + run: | + rename 's/x86_64/amd64/g' artifacts/* + rename 's/aarch_64/arm64/g' artifacts/* + + - name: Unzip jvm package + run: | + unzip artifacts/*-jvm.zip -d artifacts/extracted-jvm-package + rename 's/commafeed-.*/quarkus-app/g' artifacts/extracted-jvm-package/* + + # Docker + - name: Login to Container Registry + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3 + if: ${{ env.DOCKERHUB_USERNAME != '' }} + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + ## build but don't push for PRs and renovate + - name: Docker build - native + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 + with: + context: . + file: commafeed-server/src/main/docker/Dockerfile.native + push: false + platforms: linux/amd64,linux/arm64/v8 + + - name: Docker build - jvm + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 + with: + context: . + file: commafeed-server/src/main/docker/Dockerfile.jvm + push: false + platforms: linux/amd64,linux/arm64/v8 + + ## build and push tag + - name: Docker build and push tag - native + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 + if: ${{ github.ref_type == 'tag' }} + with: + context: . + file: commafeed-server/src/main/docker/Dockerfile.native + push: ${{ env.DOCKERHUB_USERNAME != '' }} + platforms: linux/amd64,linux/arm64/v8 + tags: | + athou/commafeed:latest-${{ matrix.database }} + athou/commafeed:${{ github.ref_name }}-${{ matrix.database }} + + - name: Docker build and push tag - jvm + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 + if: ${{ github.ref_type == 'tag' }} + with: + context: . + file: commafeed-server/src/main/docker/Dockerfile.jvm + push: ${{ env.DOCKERHUB_USERNAME != '' }} + platforms: linux/amd64,linux/arm64/v8 + tags: | + athou/commafeed:latest-${{ matrix.database }}-jvm + athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}-jvm + + ## build and push master + - name: Docker build and push master - native + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 + if: ${{ github.ref_name == 'master' }} + with: + context: . + file: commafeed-server/src/main/docker/Dockerfile.native + push: ${{ env.DOCKERHUB_USERNAME != '' }} + platforms: linux/amd64,linux/arm64/v8 + tags: athou/commafeed:master-${{ matrix.database }} + + - name: Docker build and push master - jvm + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6 + if: ${{ github.ref_name == 'master' }} + with: + context: . + file: commafeed-server/src/main/docker/Dockerfile.jvm + push: ${{ env.DOCKERHUB_USERNAME != '' }} + platforms: linux/amd64,linux/arm64/v8 + tags: athou/commafeed:master-${{ matrix.database }}-jvm + + release: + runs-on: ubuntu-latest + needs: + - build + - docker + permissions: + contents: write + if: github.ref_type == 'tag' + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + fetch-depth: 0 + + - name: Download artifacts + uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4 + with: + pattern: commafeed-* + path: ./artifacts + merge-multiple: true + + - name: Set the exec flag on the native executables + run: chmod +x artifacts/*-runner + + - name: Extract Changelog Entry + uses: mindsers/changelog-reader-action@32aa5b4c155d76c94e4ec883a223c947b2f02656 # v2 + id: changelog_reader + with: + version: ${{ github.ref_name }} + + - name: Create GitHub release + uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1 + with: + name: CommaFeed ${{ github.ref_name }} + body: ${{ steps.changelog_reader.outputs.changes }} + artifacts: ./artifacts/* + + + update-dockerhub-description: + runs-on: ubuntu-latest + needs: release + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + fetch-depth: 0 + + - name: Update Docker Hub Description + uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + repository: athou/commafeed + short-description: ${{ github.event.repository.description }} + readme-filepath: commafeed-server/src/main/docker/README.md + + + deploy-pages: + runs-on: ubuntu-latest + needs: release + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 + id: deployment diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index 84cfa227..2cdf6576 100644 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -1,18 +1,18 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -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 +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cfe8364..ddc3077c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,466 +1,466 @@ -# Changelog - -## [5.6.1] - -- Restore support for iframes in feed entries (#1688) -- There is now a package available for Arch Linux thanks to @dcelasun (#1691) - -## [5.6.0] - -- To better respect the bandwidth of feed owners, the default value of `commafeed.feed-refresh.interval-empirical` is now true. This means feeds no longer refresh exactly every 5 minutes (the default value of `commafeed.feed-refresh.interval`) but between 5 minutes and 4 hours (the default value of the new `commafeed.feed-refresh.max-interval` setting). The interval is calculated based on feed activity, so highly active feeds refresh more often (#1677) -- Many previously hardcoded values used in feed refresh interval calculation are now exposed as settings (#1677) -- Access to local addresses is now blocked to mitigate server-side request forgery (SSRF) attacks, which could potentially expose internal resources. You might want to disable the new `commafeed.http-client.block-local-addresses` setting if you subscribe to feeds only available on your local network and you trust all your users -- If a feed responds with a "429 - Too many requests" response, a backoff mechanism is triggered when the response does not contain a "Retry-After" header - -## [5.5.0] - -- CommaFeed now honors the Retry-After response header and will not try to refresh a feed sooner than the value of this header -- Audio enclosures (e.g. podcasts) now fill available entry width -- Fix an issue with some labels not correctly internationalized - -## [5.4.0] - -- An arm64 native executable is now available for download on the releases page -- The native executable Docker image now supports arm64 -- Fixed an issue with feeds that declared an invalid DOCTYPE (#1260) - -## [5.3.6] - -- Ignore invalid Cache-Control header values (#1619) - -## [5.3.5] - -- Fixed an issue with the aspect ratio of images of some feeds (#1595) -- CommaFeed now honors the Cache-Control response header and will not try to refresh a feed sooner than its max-age property (#1615) -- Added support for compilation with JDK 23+. If you're building CommaFeed from sources with a JDK 17 or 21, you may need to update it to the most recent patch version to support `-proc:full` (#1618) - -## [5.3.4] - -- Added support for Internationalized Domain Names (#1588) - -## [5.3.3] - -- Removed image bottom margins (#1587) - -## [5.3.2] - -- Fixed an issue that could cause some images from not being rendered correctly (#1587) - -## [5.3.1] - -- Fixed an issue that could cause some HTTP feeds to return a 400 error (#1572) - -## [5.3.0] - -- 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) - -## [5.2.0] - -- Added an option to keep a number of entries above the selected entry when scrolling -- Added a cache to the HTTP client to reduce the number of requests made to feeds when subscribing (#1431) -- Feeds are no longer refreshed between the moment its last user unsubscribes and the moment the feed is cleaned up (every hour) -- Fixed an issue that could cause entries to not correctly load when using keyboard navigation (#1557) - -## [5.1.1] - -- Fixed database migration issue when upgrading from 5.0.0 to 5.1.0 on MariaDB (#1544) -- When feeds without unread entries are hidden from the tree, the feed is displayed in the tree until another one is selected (#1543) - -## [5.1.0] - -- Added a setting for showing/hiding unread count in the browser's tab title/favicon (#1518) -- Fixed an issue that could prevent the app from starting on some systems (#1532) -- Added a cache busting filter for the webapp index.html and openapi documentation to make sure they are always up to date -- Reduced database cleanup log verbosity - -## [5.0.2] - -- 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 - -## [5.0.1] - -- Configure native compilation to support older CPU architectures (#1524) - -## [5.0.0] - -CommaFeed is now powered by Quarkus instead of Dropwizard. Read the rationale behind this change in -the [announcement](https://github.com/Athou/commafeed/discussions/1517). -The gist of it is that CommaFeed can now be compiled to a native binary, resulting in blazing fast startup times (around -0.3s) and very low memory footprint (< 50M). - -- CommaFeed now has a different package for each supported database. - - 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). - - 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). - - If you are using the Docker image, please read the instructions on - 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). - Please - 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. -- 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) -- 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 - 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 embedded database, please upgrade to at least version 4.3.0 before upgrading to CommaFeed 5.0.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% -- 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 - unread entries only - -## [4.5.0] - -- significantly reduce the time needed to retrieve entries or mark them as read, especially when there are a lot of - entries (#1452) -- 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 - mysql/mariadb -- 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 support for microsoft sqlserver because it's not covered with integration tests (please open an issue if you'd - like it back) - -## [4.4.1] - -- 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 - now "on desktop" instead of "always" -- 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) -- the Docker image now uses Java 21 - -## [4.4.0] - -- 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 open links in a new tab (#1333) -- add two options in the settings to toggle those buttons -- accept .opml file extension when importing and export with the .opml extension -- the "mark as read" option is no longer shown in the context menu for entries that are too old to be marked as read ( - older than `keepStatusDays`) (#1303) - -## [4.3.3] - -- fix OPML import (#1279) - -## [4.3.2] - -- added support for unix sockets (#1278) - -## [4.3.1] - -- 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) -- videos in enclosures can no longer have a width larger than the page (#1240) - -## [4.3.0] - -- 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 - database will be automatically converted to the new format -- 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) -- fix an issue that prevents new feeds from being added when mysql/mariadb is used as the database (#1239) - -## [4.2.1] - -- 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) - -## [4.2.0] - -- 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 - call to get the latest data when receiving the notification -- 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 - different timezones (#1187) - -## [4.1.0] - -- it is now possible to open the sidebar on mobile by swiping to the right (#1098) -- swiping to mark entries as read/unread changed from swiping right to left because swiping right now opens the sidebar -- the full hierarchy of categories are now displayed in the category dropdown (#1045) -- added a setting `maxEntriesAgeDays` to delete old entries based on their age during database cleanup. - The setting is disabled by default for existing installations, except for the docker image where it is enabled and set - to 365 days -- if user registrations are disabled on your instance which is the default behavior, users are redirected on the login - page instead of the welcome page when not logged in (#1185) -- the sidebar resizer is no longer shown in the middle of the screen on mobile -- 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 -- 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 -- added a memory management section to the readme, reading it is recommended if you are running CommaFeed on a server - with limited memory -- fixed an issue that caused users without an email address set to be unable to edit their profile (#1184) - -## [4.0.0] - -- 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 - marking all entries as read -- 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) -- 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 -- 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 - request, reducing CPU usage -- updated UI library Mantine to 7.0, improving performance -- the h2 embedded database is now compacted on shutdown to reclaim unused space -- the admin connector on port 8084 is now disabled in config.yml.example. Disabling it in your config.yml is - recommended (see https://github.com/Athou/commafeed/commit/929df60f09cce56020b0962ab111cd8349b271b0) -- migrated documentation from swagger 2 to openapi 3 -- added a GET method to the fever api to indicate that the endpoint is working correctly when accessed from a browser -- the websocket connection can now be disabled, the websocket ping interval and the tree reload interval can now be - configured (see config.yml.example) -- the websocket connection now works correctly when the context root of the application is not "/" -- unstable pubsubhubbub support was removed - -## [3.10.1] - -- swap next and previous buttons (#1159) -- 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 -- 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 -- add support for Java 21 - -## [3.10.0] - -- added a Fever-compatible API that is usable with mobile clients that support the Fever API (see instructions in - Settings -> Profile) -- long entry titles are no longer shortened in the detailed view -- added the "s" keyboard shortcut to star/unstar entries -- 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 - -## [3.9.0] - -- 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 custom context menu -- 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) -- add support for MariaDB 11+ -- 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 -- database cleanup batch size is now configurable -- css parsing errors are no longer logged to the standard output -- fix small errors in the api documentation - -## [3.8.1] - -- 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 - -## [3.8.0] - -- 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 -- 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) -- dramatically improve performance while scrolling -- fix broken welcome page mobile layout -- format dates in user locale instead of GMT in relative date popups - -## [3.7.0] - -- the sidebar is now resizable -- added the "f" keyboard shortcut to hide the sidebar -- 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) -- 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 -- 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 -- fix a bug that could prevent feeds and categories from being edited - -## [3.6.0] - -- 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 -- 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 -- 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 - -## [3.5.0] - -- add compatibility with the new version of the CommaFeed browser extension -- disable pull-to-refresh on mobile as it messes with vertical scrolling -- add css classes to feed entries to help with custom css rules -- 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 disable strict password policy -- add feed refresh engine metrics -- fix redis timeouts - -## [3.4.0] - -- add support for arm64 docker images -- 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 -- 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 - of feeds -- fix alignment of icon with text for category tree nodes -- fix alignment of burger button with the rest of the header on mobile - -## [3.3.2] - -- 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 -- reduced javascript bundle size by 10% - -## [3.3.1] - -- fix long feed names not being shortened to respect tree max width - -## [3.3.0] - -- there are now database changes, rolling back to 2.x will no longer be possible -- restore support for user custom CSS rules -- add support for user custom JS code that will be executed on page load - -## [3.2.0] - -- restore the welcome page -- only apply hover effect for unread entries (same as commafeed v2) -- move notifications at the bottom of the screen -- always use https for sharing urls -- add support for redis ACLs -- transition to google analytics v4 - -## [3.1.0] - -- add an even more compact layout -- 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 - mobile -- 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 - -## [3.0.1] - -- 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 - value -- allow env variable prefixed with `CF_` to override config.yml properties -- e.g. setting `CF_APP_ALLOWREGISTRATIONS=true` will set `app.allowRegistrations` to `true` - -## [3.0.0] - -- complete overhaul of the UI -- backend and frontend are now in separate maven modules -- no changes to the api or the database -- Docker images are now automatically built and available at https://hub.docker.com/r/athou/commafeed - -## [2.6.0] - -- add support for media content as a backup for missing content (useful for youtube feeds) -- correctly follow http error code 308 redirects -- 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 prevented the app to be used as an installed app on mobile devices if the context path of commafeed - was not "/" -- 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 deploying on openshift -- 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 - users that did not log in for a long time -- various dependencies upgrades (notably dropwizard from 1.3 to 2.1) -- add support for mariadb -- add support for java17+ runtime -- various security improvements - -## [2.5.0] - -- unread count is now displayed in a favicon badge when supported -- the user agent string for the bot fetching feeds is now configurable -- feed parsing performance improvements -- support for java9+ runtime -- can now properly start from an empty postgresql database - -## [2.4.0] - -- users were not able to change password or delete account -- fix api key generation -- feed entries can now be sorted alphabetically -- fix facebook sharing -- fix layout on iOS -- postgresql driver update (fix for postgres 9.6) -- various internationalization fixes -- security fixes - -## [2.3.0] - -- dropwizard upgrade 0.9.1 -- feed enclosures are hidden if they already displayed in the content -- fix youtube favicons -- various internationalization fixes - -## [2.2.0] - -- fix youtube and instagram favicon fetching -- mark as read filter was lost when a feed was rearranged with drag&drop -- feed entry categories are now displayed if available -- various performance and dependencies upgrades -- java8 is now required - -## [2.1.0] - -- dropwizard upgrade to 0.8.0 -- you have to remove the "app.contextPath" setting from your yml file, you can optionally use - server.applicationContextPath instead -- 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, - content, author or url. -- ability to use !keyword or -keyword to exclude a keyword from a search query -- facebook feeds now show user favicon instead of facebook favicon -- new dark theme 'nightsky' - -## [2.0.3] - -- internet explorer ajax cache workaround -- categories are now deletable again -- openshift support is back -- youtube feeds now show user favicon instead of youtube favicon - -## [2.0.2] - -- api using the api key is now working again -- 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 scrolling of subscriptions list on mobile -- user is now logged in after registration -- fix link to documentation on home page and about page -- fields autocomplete is disabled on the profile page -- users are able to delete their account again -- chinese and malaysian translation files are now correctly loaded -- 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 -- added link to metrics on the admin settings page -- Rome (rss library) upgrade to 1.5.0 - -## [2.0.1] - -- the redis pool no longer throws an exception when it is unable to aquire a new connection - -## [2.0.0] - -- The backend has been completely rewritten using Dropwizard instead of TomEE, resulting in a lot less memory - consumption and better overall performances. - See the README on how to build CommaFeed from now on. -- CommaFeed should no longer fetch the same feed multiple times in a row -- Users can use their username or email to log in +# Changelog + +## [5.6.1] + +- Restore support for iframes in feed entries (#1688) +- There is now a package available for Arch Linux thanks to @dcelasun (#1691) + +## [5.6.0] + +- To better respect the bandwidth of feed owners, the default value of `commafeed.feed-refresh.interval-empirical` is now true. This means feeds no longer refresh exactly every 5 minutes (the default value of `commafeed.feed-refresh.interval`) but between 5 minutes and 4 hours (the default value of the new `commafeed.feed-refresh.max-interval` setting). The interval is calculated based on feed activity, so highly active feeds refresh more often (#1677) +- Many previously hardcoded values used in feed refresh interval calculation are now exposed as settings (#1677) +- Access to local addresses is now blocked to mitigate server-side request forgery (SSRF) attacks, which could potentially expose internal resources. You might want to disable the new `commafeed.http-client.block-local-addresses` setting if you subscribe to feeds only available on your local network and you trust all your users +- If a feed responds with a "429 - Too many requests" response, a backoff mechanism is triggered when the response does not contain a "Retry-After" header + +## [5.5.0] + +- CommaFeed now honors the Retry-After response header and will not try to refresh a feed sooner than the value of this header +- Audio enclosures (e.g. podcasts) now fill available entry width +- Fix an issue with some labels not correctly internationalized + +## [5.4.0] + +- An arm64 native executable is now available for download on the releases page +- The native executable Docker image now supports arm64 +- Fixed an issue with feeds that declared an invalid DOCTYPE (#1260) + +## [5.3.6] + +- Ignore invalid Cache-Control header values (#1619) + +## [5.3.5] + +- Fixed an issue with the aspect ratio of images of some feeds (#1595) +- CommaFeed now honors the Cache-Control response header and will not try to refresh a feed sooner than its max-age property (#1615) +- Added support for compilation with JDK 23+. If you're building CommaFeed from sources with a JDK 17 or 21, you may need to update it to the most recent patch version to support `-proc:full` (#1618) + +## [5.3.4] + +- Added support for Internationalized Domain Names (#1588) + +## [5.3.3] + +- Removed image bottom margins (#1587) + +## [5.3.2] + +- Fixed an issue that could cause some images from not being rendered correctly (#1587) + +## [5.3.1] + +- Fixed an issue that could cause some HTTP feeds to return a 400 error (#1572) + +## [5.3.0] + +- 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) + +## [5.2.0] + +- Added an option to keep a number of entries above the selected entry when scrolling +- Added a cache to the HTTP client to reduce the number of requests made to feeds when subscribing (#1431) +- Feeds are no longer refreshed between the moment its last user unsubscribes and the moment the feed is cleaned up (every hour) +- Fixed an issue that could cause entries to not correctly load when using keyboard navigation (#1557) + +## [5.1.1] + +- Fixed database migration issue when upgrading from 5.0.0 to 5.1.0 on MariaDB (#1544) +- When feeds without unread entries are hidden from the tree, the feed is displayed in the tree until another one is selected (#1543) + +## [5.1.0] + +- Added a setting for showing/hiding unread count in the browser's tab title/favicon (#1518) +- Fixed an issue that could prevent the app from starting on some systems (#1532) +- Added a cache busting filter for the webapp index.html and openapi documentation to make sure they are always up to date +- Reduced database cleanup log verbosity + +## [5.0.2] + +- 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 + +## [5.0.1] + +- Configure native compilation to support older CPU architectures (#1524) + +## [5.0.0] + +CommaFeed is now powered by Quarkus instead of Dropwizard. Read the rationale behind this change in +the [announcement](https://github.com/Athou/commafeed/discussions/1517). +The gist of it is that CommaFeed can now be compiled to a native binary, resulting in blazing fast startup times (around +0.3s) and very low memory footprint (< 50M). + +- CommaFeed now has a different package for each supported database. + - 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). + - 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). + - If you are using the Docker image, please read the instructions on + 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). + Please + 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. +- 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) +- 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 + 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 embedded database, please upgrade to at least version 4.3.0 before upgrading to CommaFeed 5.0.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% +- 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 + unread entries only + +## [4.5.0] + +- significantly reduce the time needed to retrieve entries or mark them as read, especially when there are a lot of + entries (#1452) +- 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 + mysql/mariadb +- 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 support for microsoft sqlserver because it's not covered with integration tests (please open an issue if you'd + like it back) + +## [4.4.1] + +- 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 + now "on desktop" instead of "always" +- 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) +- the Docker image now uses Java 21 + +## [4.4.0] + +- 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 open links in a new tab (#1333) +- add two options in the settings to toggle those buttons +- accept .opml file extension when importing and export with the .opml extension +- the "mark as read" option is no longer shown in the context menu for entries that are too old to be marked as read ( + older than `keepStatusDays`) (#1303) + +## [4.3.3] + +- fix OPML import (#1279) + +## [4.3.2] + +- added support for unix sockets (#1278) + +## [4.3.1] + +- 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) +- videos in enclosures can no longer have a width larger than the page (#1240) + +## [4.3.0] + +- 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 + database will be automatically converted to the new format +- 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) +- fix an issue that prevents new feeds from being added when mysql/mariadb is used as the database (#1239) + +## [4.2.1] + +- 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) + +## [4.2.0] + +- 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 + call to get the latest data when receiving the notification +- 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 + different timezones (#1187) + +## [4.1.0] + +- it is now possible to open the sidebar on mobile by swiping to the right (#1098) +- swiping to mark entries as read/unread changed from swiping right to left because swiping right now opens the sidebar +- the full hierarchy of categories are now displayed in the category dropdown (#1045) +- added a setting `maxEntriesAgeDays` to delete old entries based on their age during database cleanup. + The setting is disabled by default for existing installations, except for the docker image where it is enabled and set + to 365 days +- if user registrations are disabled on your instance which is the default behavior, users are redirected on the login + page instead of the welcome page when not logged in (#1185) +- the sidebar resizer is no longer shown in the middle of the screen on mobile +- 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 +- 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 +- added a memory management section to the readme, reading it is recommended if you are running CommaFeed on a server + with limited memory +- fixed an issue that caused users without an email address set to be unable to edit their profile (#1184) + +## [4.0.0] + +- 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 + marking all entries as read +- 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) +- 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 +- 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 + request, reducing CPU usage +- updated UI library Mantine to 7.0, improving performance +- the h2 embedded database is now compacted on shutdown to reclaim unused space +- the admin connector on port 8084 is now disabled in config.yml.example. Disabling it in your config.yml is + recommended (see https://github.com/Athou/commafeed/commit/929df60f09cce56020b0962ab111cd8349b271b0) +- migrated documentation from swagger 2 to openapi 3 +- added a GET method to the fever api to indicate that the endpoint is working correctly when accessed from a browser +- the websocket connection can now be disabled, the websocket ping interval and the tree reload interval can now be + configured (see config.yml.example) +- the websocket connection now works correctly when the context root of the application is not "/" +- unstable pubsubhubbub support was removed + +## [3.10.1] + +- swap next and previous buttons (#1159) +- 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 +- 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 +- add support for Java 21 + +## [3.10.0] + +- added a Fever-compatible API that is usable with mobile clients that support the Fever API (see instructions in + Settings -> Profile) +- long entry titles are no longer shortened in the detailed view +- added the "s" keyboard shortcut to star/unstar entries +- 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 + +## [3.9.0] + +- 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 custom context menu +- 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) +- add support for MariaDB 11+ +- 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 +- database cleanup batch size is now configurable +- css parsing errors are no longer logged to the standard output +- fix small errors in the api documentation + +## [3.8.1] + +- 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 + +## [3.8.0] + +- 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 +- 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) +- dramatically improve performance while scrolling +- fix broken welcome page mobile layout +- format dates in user locale instead of GMT in relative date popups + +## [3.7.0] + +- the sidebar is now resizable +- added the "f" keyboard shortcut to hide the sidebar +- 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) +- 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 +- 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 +- fix a bug that could prevent feeds and categories from being edited + +## [3.6.0] + +- 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 +- 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 +- 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 + +## [3.5.0] + +- add compatibility with the new version of the CommaFeed browser extension +- disable pull-to-refresh on mobile as it messes with vertical scrolling +- add css classes to feed entries to help with custom css rules +- 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 disable strict password policy +- add feed refresh engine metrics +- fix redis timeouts + +## [3.4.0] + +- add support for arm64 docker images +- 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 +- 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 + of feeds +- fix alignment of icon with text for category tree nodes +- fix alignment of burger button with the rest of the header on mobile + +## [3.3.2] + +- 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 +- reduced javascript bundle size by 10% + +## [3.3.1] + +- fix long feed names not being shortened to respect tree max width + +## [3.3.0] + +- there are now database changes, rolling back to 2.x will no longer be possible +- restore support for user custom CSS rules +- add support for user custom JS code that will be executed on page load + +## [3.2.0] + +- restore the welcome page +- only apply hover effect for unread entries (same as commafeed v2) +- move notifications at the bottom of the screen +- always use https for sharing urls +- add support for redis ACLs +- transition to google analytics v4 + +## [3.1.0] + +- add an even more compact layout +- 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 + mobile +- 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 + +## [3.0.1] + +- 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 + value +- allow env variable prefixed with `CF_` to override config.yml properties +- e.g. setting `CF_APP_ALLOWREGISTRATIONS=true` will set `app.allowRegistrations` to `true` + +## [3.0.0] + +- complete overhaul of the UI +- backend and frontend are now in separate maven modules +- no changes to the api or the database +- Docker images are now automatically built and available at https://hub.docker.com/r/athou/commafeed + +## [2.6.0] + +- add support for media content as a backup for missing content (useful for youtube feeds) +- correctly follow http error code 308 redirects +- 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 prevented the app to be used as an installed app on mobile devices if the context path of commafeed + was not "/" +- 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 deploying on openshift +- 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 + users that did not log in for a long time +- various dependencies upgrades (notably dropwizard from 1.3 to 2.1) +- add support for mariadb +- add support for java17+ runtime +- various security improvements + +## [2.5.0] + +- unread count is now displayed in a favicon badge when supported +- the user agent string for the bot fetching feeds is now configurable +- feed parsing performance improvements +- support for java9+ runtime +- can now properly start from an empty postgresql database + +## [2.4.0] + +- users were not able to change password or delete account +- fix api key generation +- feed entries can now be sorted alphabetically +- fix facebook sharing +- fix layout on iOS +- postgresql driver update (fix for postgres 9.6) +- various internationalization fixes +- security fixes + +## [2.3.0] + +- dropwizard upgrade 0.9.1 +- feed enclosures are hidden if they already displayed in the content +- fix youtube favicons +- various internationalization fixes + +## [2.2.0] + +- fix youtube and instagram favicon fetching +- mark as read filter was lost when a feed was rearranged with drag&drop +- feed entry categories are now displayed if available +- various performance and dependencies upgrades +- java8 is now required + +## [2.1.0] + +- dropwizard upgrade to 0.8.0 +- you have to remove the "app.contextPath" setting from your yml file, you can optionally use + server.applicationContextPath instead +- 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, + content, author or url. +- ability to use !keyword or -keyword to exclude a keyword from a search query +- facebook feeds now show user favicon instead of facebook favicon +- new dark theme 'nightsky' + +## [2.0.3] + +- internet explorer ajax cache workaround +- categories are now deletable again +- openshift support is back +- youtube feeds now show user favicon instead of youtube favicon + +## [2.0.2] + +- api using the api key is now working again +- 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 scrolling of subscriptions list on mobile +- user is now logged in after registration +- fix link to documentation on home page and about page +- fields autocomplete is disabled on the profile page +- users are able to delete their account again +- chinese and malaysian translation files are now correctly loaded +- 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 +- added link to metrics on the admin settings page +- Rome (rss library) upgrade to 1.5.0 + +## [2.0.1] + +- the redis pool no longer throws an exception when it is unable to aquire a new connection + +## [2.0.0] + +- The backend has been completely rewritten using Dropwizard instead of TomEE, resulting in a lot less memory + consumption and better overall performances. + See the README on how to build CommaFeed from now on. +- CommaFeed should no longer fetch the same feed multiple times in a row +- Users can use their username or email to log in diff --git a/LICENSE b/LICENSE index 014a5538..8f7d051d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,31 +1,31 @@ -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 -1. Definitions. -"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. -"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. -"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. -"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. -"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. -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. -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. -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 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. -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. -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. -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. -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. -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. +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 +1. Definitions. +"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. +"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. +"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. +"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. +"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. +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. +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. +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 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. +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. +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. +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. +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. +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 \ No newline at end of file diff --git a/commafeed-client/pom.xml b/commafeed-client/pom.xml index b329e46b..e255a4e8 100644 --- a/commafeed-client/pom.xml +++ b/commafeed-client/pom.xml @@ -1,96 +1,96 @@ - - - 4.0.0 - - - com.commafeed - commafeed - 5.6.1 - - commafeed-client - CommaFeed Client - - - - v22.14.0 - - 11.2.0 - - - - - - com.github.eirslett - frontend-maven-plugin - 1.15.1 - - - - install node and npm - - install-node-and-npm - - compile - - ${node.version} - ${npm.version} - - - - npm install - - npm - - compile - - ci - - - - npm run test - - npm - - compile - - run test:ci - - - - npm run build - - npm - - compile - - run build - - - - - - maven-resources-plugin - 3.3.1 - - - copy web interface to resources - prepare-package - - copy-resources - - - ${project.build.directory}/classes/META-INF/resources - - - dist - false - - - - - - - - + + + 4.0.0 + + + com.commafeed + commafeed + 5.6.1 + + commafeed-client + CommaFeed Client + + + + v22.14.0 + + 11.2.0 + + + + + + com.github.eirslett + frontend-maven-plugin + 1.15.1 + + + + install node and npm + + install-node-and-npm + + compile + + ${node.version} + ${npm.version} + + + + npm install + + npm + + compile + + ci + + + + npm run test + + npm + + compile + + run test:ci + + + + npm run build + + npm + + compile + + run build + + + + + + maven-resources-plugin + 3.3.1 + + + copy web interface to resources + prepare-package + + copy-resources + + + ${project.build.directory}/classes/META-INF/resources + + + dist + false + + + + + + + + \ No newline at end of file diff --git a/commafeed-client/public/app-icon-114.png b/commafeed-client/public/app-icon-114.png index 5c045153..f81659b7 100644 Binary files a/commafeed-client/public/app-icon-114.png and b/commafeed-client/public/app-icon-114.png differ diff --git a/commafeed-client/public/app-icon-144.png b/commafeed-client/public/app-icon-144.png index 6c67460f..cac00e26 100644 Binary files a/commafeed-client/public/app-icon-144.png and b/commafeed-client/public/app-icon-144.png differ diff --git a/commafeed-client/public/app-icon-192.png b/commafeed-client/public/app-icon-192.png index 71343c48..d557cdd3 100644 Binary files a/commafeed-client/public/app-icon-192.png and b/commafeed-client/public/app-icon-192.png differ diff --git a/commafeed-client/public/app-icon-72.png b/commafeed-client/public/app-icon-72.png index 4ce035ba..b690d07b 100644 Binary files a/commafeed-client/public/app-icon-72.png and b/commafeed-client/public/app-icon-72.png differ diff --git a/commafeed-client/src/assets/welcome_page_dark.png b/commafeed-client/src/assets/welcome_page_dark.png index 1d7f6309..d9fb0b04 100644 Binary files a/commafeed-client/src/assets/welcome_page_dark.png and b/commafeed-client/src/assets/welcome_page_dark.png differ diff --git a/commafeed-client/src/assets/welcome_page_light.png b/commafeed-client/src/assets/welcome_page_light.png index 2b562dfb..0da0d937 100644 Binary files a/commafeed-client/src/assets/welcome_page_light.png and b/commafeed-client/src/assets/welcome_page_light.png differ diff --git a/commafeed-server/dev/EclipseCodeFormatter.xml b/commafeed-server/dev/EclipseCodeFormatter.xml index 82233215..e4c593d3 100644 --- a/commafeed-server/dev/EclipseCodeFormatter.xml +++ b/commafeed-server/dev/EclipseCodeFormatter.xml @@ -1,401 +1,401 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/commafeed-server/dev/eclipse.importorder b/commafeed-server/dev/eclipse.importorder index a4ee263e..f93d1c1c 100644 --- a/commafeed-server/dev/eclipse.importorder +++ b/commafeed-server/dev/eclipse.importorder @@ -1,7 +1,7 @@ -#Organize Import Order -#Wed Jan 29 15:15:04 CET 2025 -0=java -1=javax -2=jakarta -3=org -4=com +#Organize Import Order +#Wed Jan 29 15:15:04 CET 2025 +0=java +1=javax +2=jakarta +3=org +4=com diff --git a/commafeed-server/playwright_code_generator.sh b/commafeed-server/playwright_code_generator.sh index f10da601..86980273 100644 --- a/commafeed-server/playwright_code_generator.sh +++ b/commafeed-server/playwright_code_generator.sh @@ -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" \ No newline at end of file diff --git a/commafeed-server/pom.xml b/commafeed-server/pom.xml index 1d0b8924..8a8687ac 100644 --- a/commafeed-server/pom.xml +++ b/commafeed-server/pom.xml @@ -1,602 +1,601 @@ - - - 4.0.0 - - - com.commafeed - commafeed - 5.6.1 - - commafeed-server - CommaFeed Server - - - 3.19.2 - 6.10.1 - 2.1.0 - 2.2.28 - - h2 - - - - - - io.quarkus.platform - quarkus-bom - ${quarkus.version} - pom - import - - - - - - - - kr.motd.maven - os-maven-plugin - 1.7.1 - - - - - maven-help-plugin - 3.5.1 - - - initialize - - active-profiles - - - - - - org.codehaus.mojo - properties-maven-plugin - 1.2.1 - - - - set-system-properties - - - - - - - quarkus.datasource.db-kind - ${build.database} - - - - - - io.quarkus.platform - quarkus-maven-plugin - ${quarkus.version} - true - - - - build - generate-code - generate-code-tests - native-image-agent - - - - commafeed-${project.version} - - -${build.database}-${os.detected.name}-${os.detected.arch}-runner - - - - - - - - io.quarkus - quarkus-config-doc-maven-plugin - ${quarkus.version} - true - - - default-generate-config-doc - process-test-resources - - generate-config-doc - - - markdown - - - - - - org.codehaus.mojo - exec-maven-plugin - 3.5.0 - - - io.quarkus - quarkus-extension-processor - ${quarkus.version} - - - - true - com.commafeed.tools.CommaFeedPropertiesGenerator - - ${project.build.directory} - - - - - process-test-resources - - java - - - - - - - org.apache.maven.plugins - maven-assembly-plugin - 3.7.1 - - - package - - single - - - commafeed-${project.version}-${build.database}-jvm - false - - src/main/assembly/zip-quarkus-app.xml - - - - - - - org.apache.maven.plugins - maven-surefire-plugin - 3.5.2 - - - org.jboss.logmanager.LogManager - ${build.database} - - - - - org.apache.maven.plugins - maven-failsafe-plugin - 3.5.2 - - - - integration-test - verify - - - - - - ${project.build.directory}/${project.build.finalName}-runner - - org.jboss.logmanager.LogManager - ${build.database} - - - - - - io.quarkus - quarkus-jdbc-h2 - ${quarkus.version} - - - io.quarkus - quarkus-jdbc-mysql - ${quarkus.version} - - - io.quarkus - quarkus-jdbc-mariadb - ${quarkus.version} - - - io.quarkus - quarkus-jdbc-postgresql - ${quarkus.version} - - - - - io.github.git-commit-id - git-commit-id-maven-plugin - 9.0.1 - - - - revision - - - - - true - ${project.build.outputDirectory}/git.properties - - false - false - - - - io.swagger.core.v3 - swagger-maven-plugin-jakarta - ${swagger.version} - - - ${project.build.directory}/classes/META-INF/resources - JSONANDYAML - - com.commafeed.frontend.resource - com.commafeed.frontend.model - com.commafeed.frontend.model.request - - true - - - - compile - - resolve - - - - - - org.apache.maven.plugins - maven-checkstyle-plugin - 3.6.0 - - - com.puppycrawl.tools - checkstyle - 10.21.4 - - - - - validate - validate - - check - - - - - true - true - false - - ${project.build.sourceDirectory} - - - ${project.build.testSourceDirectory} - - true - dev/checkstyle.xml - - - - com.diffplug.spotless - spotless-maven-plugin - 2.44.3 - - - - validate - - check - - - - - UTF-8 - WINDOWS - - - ${project.basedir}/dev/EclipseCodeFormatter.xml - - - ${project.basedir}/dev/eclipse.importorder - - - - - - - - - - com.commafeed - commafeed-client - 5.6.1 - - - - - org.projectlombok - lombok - 1.18.36 - provided - - - org.kohsuke.metainf-services - metainf-services - 1.11 - provided - - - io.quarkus - quarkus-extension-processor - ${quarkus.version} - provided - - - - - io.quarkus - quarkus-arc - - - io.quarkus - quarkus-security - - - io.quarkus - quarkus-hibernate-validator - - - io.quarkus - quarkus-rest - - - io.quarkus - quarkus-rest-jackson - - - io.quarkus - quarkus-websockets - - - io.quarkus - quarkus-mailer - - - io.quarkus - quarkus-hibernate-orm - - - io.quarkus - quarkus-liquibase - - - - io.dropwizard.metrics - metrics-json - 4.2.30 - - - io.swagger.core.v3 - swagger-annotations - ${swagger.version} - - - io.github.openfeign.querydsl - querydsl-apt - ${querydsl.version} - provided - jakarta - - - io.github.openfeign.querydsl - querydsl-jpa - ${querydsl.version} - - - com.google.guava - guava - - - org.apache.commons - commons-collections4 - 4.4 - - - org.apache.commons - commons-math3 - 3.6.1 - - - org.apache.commons - commons-jexl - 2.1.1 - - - commons-logging - commons-logging - - - - - org.passay - passay - 1.6.6 - - - com.rometools - rome - ${rome.version} - - - com.rometools - rome-modules - ${rome.version} - - - com.rometools - rome-opml - ${rome.version} - - - org.ahocorasick - ahocorasick - 0.6.3 - - - org.jsoup - jsoup - 1.19.1 - - - com.ibm.icu - icu4j - 76.1 - - - net.sourceforge.cssparser - cssparser - 0.9.30 - - - org.netpreserve - urlcanon - 0.4.0 - - - org.apache.httpcomponents.client5 - httpclient5 - 5.4.2 - - - - org.brotli - dec - 0.1.2 - - - io.github.hakky54 - sslcontext-kickstart-for-apache5 - 9.1.0 - - - - - io.quarkus - quarkus-junit5-mockito - test - - - org.mock-server - mockserver-junit-jupiter - 5.15.0 - test - - - io.rest-assured - rest-assured - test - - - org.awaitility - awaitility - test - - - com.microsoft.playwright - playwright - 1.50.0 - test - - - org.reflections - reflections - 0.10.2 - test - - - - - - native - - - native - - - - true - - - - - h2 - - true - - - h2 - - - - io.quarkus - quarkus-jdbc-h2 - - - - - mysql - - mysql - - - - io.quarkus - quarkus-jdbc-mysql - - - - - mariadb - - mariadb - - - - io.quarkus - quarkus-jdbc-mariadb - - - - - postgresql - - postgresql - - - - io.quarkus - quarkus-jdbc-postgresql - - - - - + + + 4.0.0 + + + com.commafeed + commafeed + 5.6.1 + + commafeed-server + CommaFeed Server + + + 3.19.2 + 6.10.1 + 2.1.0 + 2.2.28 + + h2 + + + + + + io.quarkus.platform + quarkus-bom + ${quarkus.version} + pom + import + + + + + + + + kr.motd.maven + os-maven-plugin + 1.7.1 + + + + + maven-help-plugin + 3.5.1 + + + initialize + + active-profiles + + + + + + org.codehaus.mojo + properties-maven-plugin + 1.2.1 + + + + set-system-properties + + + + + + + quarkus.datasource.db-kind + ${build.database} + + + + + + io.quarkus.platform + quarkus-maven-plugin + ${quarkus.version} + true + + + + build + generate-code + generate-code-tests + native-image-agent + + + + commafeed-${project.version} + + -${build.database}-${os.detected.name}-${os.detected.arch}-runner + + + + + + + + io.quarkus + quarkus-config-doc-maven-plugin + ${quarkus.version} + true + + + default-generate-config-doc + process-test-resources + + generate-config-doc + + + markdown + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + true + com.commafeed.tools.CommaFeedPropertiesGenerator + + ${project.build.directory} + + + + + process-test-resources + + java + + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.7.1 + + + package + + single + + + commafeed-${project.version}-${build.database}-jvm + false + + src/main/assembly/zip-quarkus-app.xml + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + org.jboss.logmanager.LogManager + ${build.database} + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.5.2 + + + + integration-test + verify + + + + + + ${project.build.directory}/${project.build.finalName}-runner + + org.jboss.logmanager.LogManager + ${build.database} + + + + + + io.quarkus + quarkus-jdbc-h2 + ${quarkus.version} + + + io.quarkus + quarkus-jdbc-mysql + ${quarkus.version} + + + io.quarkus + quarkus-jdbc-mariadb + ${quarkus.version} + + + io.quarkus + quarkus-jdbc-postgresql + ${quarkus.version} + + + + + io.github.git-commit-id + git-commit-id-maven-plugin + 9.0.1 + + + + revision + + + + + true + ${project.build.outputDirectory}/git.properties + + false + false + + + + io.swagger.core.v3 + swagger-maven-plugin-jakarta + ${swagger.version} + + + ${project.build.directory}/classes/META-INF/resources + JSONANDYAML + + com.commafeed.frontend.resource + com.commafeed.frontend.model + com.commafeed.frontend.model.request + + true + + + + compile + + resolve + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.6.0 + + + com.puppycrawl.tools + checkstyle + 10.21.4 + + + + + validate + validate + + check + + + + + true + true + false + + ${project.build.sourceDirectory} + + + ${project.build.testSourceDirectory} + + true + dev/checkstyle.xml + + + + com.diffplug.spotless + spotless-maven-plugin + 2.44.3 + + + + validate + + check + + + + + UTF-8 + + + ${project.basedir}/dev/EclipseCodeFormatter.xml + + + ${project.basedir}/dev/eclipse.importorder + + + + + + + + + + com.commafeed + commafeed-client + 5.6.1 + + + + + org.projectlombok + lombok + 1.18.36 + provided + + + org.kohsuke.metainf-services + metainf-services + 1.11 + provided + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + provided + + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-security + + + io.quarkus + quarkus-hibernate-validator + + + io.quarkus + quarkus-rest + + + io.quarkus + quarkus-rest-jackson + + + io.quarkus + quarkus-websockets + + + io.quarkus + quarkus-mailer + + + io.quarkus + quarkus-hibernate-orm + + + io.quarkus + quarkus-liquibase + + + + io.dropwizard.metrics + metrics-json + 4.2.30 + + + io.swagger.core.v3 + swagger-annotations + ${swagger.version} + + + io.github.openfeign.querydsl + querydsl-apt + ${querydsl.version} + provided + jakarta + + + io.github.openfeign.querydsl + querydsl-jpa + ${querydsl.version} + + + com.google.guava + guava + + + org.apache.commons + commons-collections4 + 4.4 + + + org.apache.commons + commons-math3 + 3.6.1 + + + org.apache.commons + commons-jexl + 2.1.1 + + + commons-logging + commons-logging + + + + + org.passay + passay + 1.6.6 + + + com.rometools + rome + ${rome.version} + + + com.rometools + rome-modules + ${rome.version} + + + com.rometools + rome-opml + ${rome.version} + + + org.ahocorasick + ahocorasick + 0.6.3 + + + org.jsoup + jsoup + 1.19.1 + + + com.ibm.icu + icu4j + 76.1 + + + net.sourceforge.cssparser + cssparser + 0.9.30 + + + org.netpreserve + urlcanon + 0.4.0 + + + org.apache.httpcomponents.client5 + httpclient5 + 5.4.2 + + + + org.brotli + dec + 0.1.2 + + + io.github.hakky54 + sslcontext-kickstart-for-apache5 + 9.1.0 + + + + + io.quarkus + quarkus-junit5-mockito + test + + + org.mock-server + mockserver-junit-jupiter + 5.15.0 + test + + + io.rest-assured + rest-assured + test + + + org.awaitility + awaitility + test + + + com.microsoft.playwright + playwright + 1.50.0 + test + + + org.reflections + reflections + 0.10.2 + test + + + + + + native + + + native + + + + true + + + + + h2 + + true + + + h2 + + + + io.quarkus + quarkus-jdbc-h2 + + + + + mysql + + mysql + + + + io.quarkus + quarkus-jdbc-mysql + + + + + mariadb + + mariadb + + + + io.quarkus + quarkus-jdbc-mariadb + + + + + postgresql + + postgresql + + + + io.quarkus + quarkus-jdbc-postgresql + + + + + diff --git a/commafeed-server/src/main/assembly/zip-quarkus-app.xml b/commafeed-server/src/main/assembly/zip-quarkus-app.xml index 01fb873a..3fd69864 100644 --- a/commafeed-server/src/main/assembly/zip-quarkus-app.xml +++ b/commafeed-server/src/main/assembly/zip-quarkus-app.xml @@ -1,21 +1,21 @@ - - - zip-quarkus-app - - true - commafeed-${project.version}-${build.database} - - - zip - - - - ${project.build.directory}/quarkus-app - / - - **/* - - - + + + zip-quarkus-app + + true + commafeed-${project.version}-${build.database} + + + zip + + + + ${project.build.directory}/quarkus-app + / + + **/* + + + \ No newline at end of file diff --git a/commafeed-server/src/main/docker/README.md b/commafeed-server/src/main/docker/README.md index 465230d5..5544c8df 100644 --- a/commafeed-server/src/main/docker/README.md +++ b/commafeed-server/src/main/docker/README.md @@ -1,95 +1,95 @@ -# CommaFeed - -Official docker images for https://github.com/Athou/commafeed/ - -## Quickstart - -Start CommaFeed with a H2 embedded database. Then login as `admin/admin` on http://localhost:8082/ - -### 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-compose - -``` -services: - commafeed: - image: athou/commafeed:latest-h2 - restart: unless-stopped - volumes: - - /path/to/commafeed/db:/commafeed/data - deploy: - resources: - limits: - memory: 256M - ports: - - 8082:8082 -``` - -## Advanced - -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`): - -``` -services: - commafeed: - image: athou/commafeed:latest-postgresql - restart: unless-stopped - environment: - - QUARKUS_DATASOURCE_JDBC_URL=jdbc:postgresql://postgresql:5432/commafeed - - QUARKUS_DATASOURCE_USERNAME=commafeed - - QUARKUS_DATASOURCE_PASSWORD=commafeed - deploy: - resources: - limits: - memory: 256M - ports: - - 8082:8082 - - postgresql: - image: postgres:latest - restart: unless-stopped - environment: - POSTGRES_USER: commafeed - POSTGRES_PASSWORD: commafeed - POSTGRES_DB: commafeed - volumes: - - /path/to/commafeed/db:/var/lib/postgresql/data -``` - -CommaFeed also supports: - -- MySQL: - `QUARKUS_DATASOURCE_JDBC_URL=jdbc:mysql://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC` -- MariaDB: - `QUARKUS_DATASOURCE_JDBC_URL=jdbc:mariadb://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC` - -## Configuration - -All [CommaFeed settings](https://athou.github.io/commafeed/documentation) are -optional and have sensible default values. - -Settings are overrideable with environment variables. For instance, `commafeed.feed-refresh.interval-empirical` can be -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, -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). -All other Quarkus settings can be found [here](https://quarkus.io/guides/all-config). - -### 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. - -## Docker tags - -Tags are of the form `-[-jvm]` where: - -- `` is either: - - a specific CommaFeed version (e.g. `5.0.0`) - - `latest` (always points to the latest version) - - `master` (always points to the latest git commit) -- `` 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. +# CommaFeed + +Official docker images for https://github.com/Athou/commafeed/ + +## Quickstart + +Start CommaFeed with a H2 embedded database. Then login as `admin/admin` on http://localhost:8082/ + +### 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-compose + +``` +services: + commafeed: + image: athou/commafeed:latest-h2 + restart: unless-stopped + volumes: + - /path/to/commafeed/db:/commafeed/data + deploy: + resources: + limits: + memory: 256M + ports: + - 8082:8082 +``` + +## Advanced + +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`): + +``` +services: + commafeed: + image: athou/commafeed:latest-postgresql + restart: unless-stopped + environment: + - QUARKUS_DATASOURCE_JDBC_URL=jdbc:postgresql://postgresql:5432/commafeed + - QUARKUS_DATASOURCE_USERNAME=commafeed + - QUARKUS_DATASOURCE_PASSWORD=commafeed + deploy: + resources: + limits: + memory: 256M + ports: + - 8082:8082 + + postgresql: + image: postgres:latest + restart: unless-stopped + environment: + POSTGRES_USER: commafeed + POSTGRES_PASSWORD: commafeed + POSTGRES_DB: commafeed + volumes: + - /path/to/commafeed/db:/var/lib/postgresql/data +``` + +CommaFeed also supports: + +- MySQL: + `QUARKUS_DATASOURCE_JDBC_URL=jdbc:mysql://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC` +- MariaDB: + `QUARKUS_DATASOURCE_JDBC_URL=jdbc:mariadb://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC` + +## Configuration + +All [CommaFeed settings](https://athou.github.io/commafeed/documentation) are +optional and have sensible default values. + +Settings are overrideable with environment variables. For instance, `commafeed.feed-refresh.interval-empirical` can be +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, +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). +All other Quarkus settings can be found [here](https://quarkus.io/guides/all-config). + +### 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. + +## Docker tags + +Tags are of the form `-[-jvm]` where: + +- `` is either: + - a specific CommaFeed version (e.g. `5.0.0`) + - `latest` (always points to the latest version) + - `master` (always points to the latest git commit) +- `` 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. diff --git a/commafeed-server/src/main/java/com/commafeed/CommaFeedApplication.java b/commafeed-server/src/main/java/com/commafeed/CommaFeedApplication.java index 8ba64bb5..c5a57086 100644 --- a/commafeed-server/src/main/java/com/commafeed/CommaFeedApplication.java +++ b/commafeed-server/src/main/java/com/commafeed/CommaFeedApplication.java @@ -1,41 +1,41 @@ -package com.commafeed; - -import jakarta.enterprise.event.Observes; -import jakarta.inject.Singleton; - -import com.commafeed.backend.feed.FeedRefreshEngine; -import com.commafeed.backend.service.db.DatabaseStartupService; -import com.commafeed.backend.task.TaskScheduler; -import com.commafeed.security.password.PasswordConstraintValidator; - -import io.quarkus.runtime.ShutdownEvent; -import io.quarkus.runtime.StartupEvent; -import lombok.RequiredArgsConstructor; - -@Singleton -@RequiredArgsConstructor -public class CommaFeedApplication { - - public static final String USERNAME_ADMIN = "admin"; - public static final String USERNAME_DEMO = "demo"; - - private final DatabaseStartupService databaseStartupService; - private final FeedRefreshEngine feedRefreshEngine; - private final TaskScheduler taskScheduler; - private final CommaFeedConfiguration config; - - public void start(@Observes StartupEvent ev) { - PasswordConstraintValidator.setStrict(config.users().strictPasswordPolicy()); - - databaseStartupService.populateInitialData(); - - feedRefreshEngine.start(); - taskScheduler.start(); - } - - public void stop(@Observes ShutdownEvent ev) { - feedRefreshEngine.stop(); - taskScheduler.stop(); - } - -} +package com.commafeed; + +import jakarta.enterprise.event.Observes; +import jakarta.inject.Singleton; + +import com.commafeed.backend.feed.FeedRefreshEngine; +import com.commafeed.backend.service.db.DatabaseStartupService; +import com.commafeed.backend.task.TaskScheduler; +import com.commafeed.security.password.PasswordConstraintValidator; + +import io.quarkus.runtime.ShutdownEvent; +import io.quarkus.runtime.StartupEvent; +import lombok.RequiredArgsConstructor; + +@Singleton +@RequiredArgsConstructor +public class CommaFeedApplication { + + public static final String USERNAME_ADMIN = "admin"; + public static final String USERNAME_DEMO = "demo"; + + private final DatabaseStartupService databaseStartupService; + private final FeedRefreshEngine feedRefreshEngine; + private final TaskScheduler taskScheduler; + private final CommaFeedConfiguration config; + + public void start(@Observes StartupEvent ev) { + PasswordConstraintValidator.setStrict(config.users().strictPasswordPolicy()); + + databaseStartupService.populateInitialData(); + + feedRefreshEngine.start(); + taskScheduler.start(); + } + + public void stop(@Observes ShutdownEvent ev) { + feedRefreshEngine.stop(); + taskScheduler.stop(); + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/CommaFeedConfiguration.java b/commafeed-server/src/main/java/com/commafeed/CommaFeedConfiguration.java index e68a04d5..dbbd3e4d 100644 --- a/commafeed-server/src/main/java/com/commafeed/CommaFeedConfiguration.java +++ b/commafeed-server/src/main/java/com/commafeed/CommaFeedConfiguration.java @@ -1,366 +1,366 @@ -package com.commafeed; - -import java.time.Duration; -import java.time.Instant; -import java.util.Optional; - -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.Positive; - -import com.commafeed.backend.feed.FeedRefreshIntervalCalculator; - -import io.quarkus.runtime.annotations.ConfigDocSection; -import io.quarkus.runtime.annotations.ConfigPhase; -import io.quarkus.runtime.annotations.ConfigRoot; -import io.quarkus.runtime.configuration.MemorySize; -import io.smallrye.config.ConfigMapping; -import io.smallrye.config.WithDefault; - -/** - * CommaFeed configuration - * - * Default values are for production, they can be overridden in application.properties for other profiles - */ -@ConfigMapping(prefix = "commafeed") -@ConfigRoot(phase = ConfigPhase.RUN_TIME) -public interface CommaFeedConfiguration { - /** - * Whether to expose a robots.txt file that disallows web crawlers and search engine indexers. - */ - @WithDefault("true") - boolean hideFromWebCrawlers(); - - /** - * 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. - */ - @WithDefault("false") - boolean imageProxyEnabled(); - - /** - * Enable password recovery via email. - * - * Quarkus mailer will need to be configured. - */ - @WithDefault("false") - boolean passwordRecoveryEnabled(); - - /** - * Message displayed in a notification at the bottom of the page. - */ - Optional announcement(); - - /** - * Google Analytics tracking code. - */ - Optional googleAnalyticsTrackingCode(); - - /** - * Google Auth key for fetching Youtube channel favicons. - */ - Optional googleAuthKey(); - - /** - * HTTP client configuration - */ - @ConfigDocSection - HttpClient httpClient(); - - /** - * Feed refresh engine settings. - */ - @ConfigDocSection - FeedRefresh feedRefresh(); - - /** - * Database settings. - */ - @ConfigDocSection - Database database(); - - /** - * Users settings. - */ - @ConfigDocSection - Users users(); - - /** - * Websocket settings. - */ - @ConfigDocSection - Websocket websocket(); - - interface HttpClient { - /** - * User-Agent string that will be used by the http client, leave empty for the default one. - */ - Optional userAgent(); - - /** - * Time to wait for a connection to be established. - */ - @WithDefault("5s") - Duration connectTimeout(); - - /** - * Time to wait for SSL handshake to complete. - */ - @WithDefault("5s") - Duration sslHandshakeTimeout(); - - /** - * Time to wait between two packets before timeout. - */ - @WithDefault("10s") - Duration socketTimeout(); - - /** - * Time to wait for the full response to be received. - */ - @WithDefault("10s") - Duration responseTimeout(); - - /** - * Time to live for a connection in the pool. - */ - @WithDefault("30s") - Duration connectionTimeToLive(); - - /** - * Time between eviction runs for idle connections. - */ - @WithDefault("1m") - Duration idleConnectionsEvictionInterval(); - - /** - * If a feed is larger than this, it will be discarded to prevent memory issues while parsing the feed. - */ - @WithDefault("5M") - MemorySize maxResponseSize(); - - /** - * Prevent access to local addresses to mitigate server-side request forgery (SSRF) attacks, which could potentially expose internal - * resources. - * - * You may want to disable this if you subscribe to feeds that are only available on your local network and you trust all users of - * your CommaFeed instance. - */ - @WithDefault("true") - boolean blockLocalAddresses(); - - /** - * HTTP client cache configuration - */ - @ConfigDocSection - HttpClientCache cache(); - } - - 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 - * first time or when clicking "fetch all my feeds now"). - */ - @WithDefault("true") - boolean enabled(); - - /** - * Maximum amount of memory the cache can use. - */ - @WithDefault("10M") - MemorySize maximumMemorySize(); - - /** - * Duration after which an entry is removed from the cache. - */ - @WithDefault("1m") - Duration expiration(); - } - - interface FeedRefresh { - /** - * Default amount of time CommaFeed will wait before refreshing a feed. - */ - @WithDefault("5m") - Duration interval(); - - /** - * Maximum amount of time CommaFeed will wait before refreshing a feed. This is used as an upper bound when: - * - *
    - *
  • an error occurs while refreshing a feed and we're backing off exponentially
  • - *
  • we receive a Cache-Control header from the feed
  • - *
  • we receive a Retry-After header from the feed
  • - *
- */ - @WithDefault("4h") - Duration maxInterval(); - - /** - * If enabled, CommaFeed will calculate the next refresh time based on the feed's average time between entries and the time since - * the last entry was published. The interval will be sometimes between the default refresh interval - * (`commafeed.feed-refresh.interval`) and the maximum refresh interval (`commafeed.feed-refresh.max-interval`). - * - * See {@link FeedRefreshIntervalCalculator} for details. - */ - @WithDefault("true") - boolean intervalEmpirical(); - - /** - * Feed refresh engine error handling settings. - */ - @ConfigDocSection - FeedRefreshErrorHandling errors(); - - /** - * Amount of http threads used to fetch feeds. - */ - @Min(1) - @WithDefault("3") - int httpThreads(); - - /** - * Amount of threads used to insert new entries in the database. - */ - @Min(1) - @WithDefault("1") - int databaseThreads(); - - /** - * Duration after which a user is considered inactive. Feeds for inactive users are not refreshed until they log in again. - * - * 0 to disable. - */ - @WithDefault("0") - Duration userInactivityPeriod(); - - /** - * Duration after which the evaluation of a filtering expresion to mark an entry as read is considered to have timed out. - */ - @WithDefault("500ms") - Duration filteringExpressionEvaluationTimeout(); - - /** - * Duration after which the "Fetch all my feeds now" action is available again after use to avoid spamming feeds. - */ - @WithDefault("0") - Duration forceRefreshCooldownDuration(); - } - - interface FeedRefreshErrorHandling { - /** - * Number of retries before backoff is applied. - */ - @Min(0) - @WithDefault("3") - int retriesBeforeBackoff(); - - /** - * Duration to wait before retrying after an error. Will be multiplied by the number of errors since the last successful fetch. - */ - @WithDefault("1h") - Duration backoffInterval(); - } - - interface Database { - /** - * Timeout applied to all database queries. - * - * 0 to disable. - */ - @WithDefault("0") - Duration queryTimeout(); - - /** - * Database cleanup settings. - */ - @ConfigDocSection - Cleanup cleanup(); - - interface Cleanup { - /** - * Maximum age of feed entries in the database. Older entries will be deleted. - * - * 0 to disable. - */ - @WithDefault("365d") - Duration entriesMaxAge(); - - /** - * Maximum age of feed entry statuses (read/unread) in the database. Older statuses will be deleted. - * - * 0 to disable. - */ - @WithDefault("0") - Duration statusesMaxAge(); - - /** - * Maximum number of entries per feed to keep in the database. - * - * 0 to disable. - */ - @WithDefault("500") - int maxFeedCapacity(); - - /** - * Limit the number of feeds a user can subscribe to. - * - * 0 to disable. - */ - @WithDefault("0") - int maxFeedsPerUser(); - - /** - * Rows to delete per query while cleaning up old entries. - */ - @Positive - @WithDefault("100") - int batchSize(); - - default Instant statusesInstantThreshold() { - return statusesMaxAge().toMillis() > 0 ? Instant.now().minus(statusesMaxAge()) : null; - } - } - } - - interface Users { - /** - * Whether to let users create accounts for themselves. - */ - @WithDefault("false") - boolean allowRegistrations(); - - /** - * Whether to enable strict password validation (1 uppercase char, 1 lowercase char, 1 digit, 1 special char). - */ - @WithDefault("true") - boolean strictPasswordPolicy(); - - /** - * Whether to create a demo account the first time the app starts. - */ - @WithDefault("false") - boolean createDemoAccount(); - } - - interface Websocket { - /** - * Enable websocket connection so the server can notify web clients that there are new entries for feeds. - */ - @WithDefault("true") - boolean enabled(); - - /** - * Interval at which the client will send a ping message on the websocket to keep the connection alive. - */ - @WithDefault("15m") - Duration pingInterval(); - - /** - * If the websocket connection is disabled or the connection is lost, the client will reload the feed tree at this interval. - */ - @WithDefault("30s") - Duration treeReloadInterval(); - } - -} +package com.commafeed; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; + +import com.commafeed.backend.feed.FeedRefreshIntervalCalculator; + +import io.quarkus.runtime.annotations.ConfigDocSection; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.quarkus.runtime.configuration.MemorySize; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +/** + * CommaFeed configuration + * + * Default values are for production, they can be overridden in application.properties for other profiles + */ +@ConfigMapping(prefix = "commafeed") +@ConfigRoot(phase = ConfigPhase.RUN_TIME) +public interface CommaFeedConfiguration { + /** + * Whether to expose a robots.txt file that disallows web crawlers and search engine indexers. + */ + @WithDefault("true") + boolean hideFromWebCrawlers(); + + /** + * 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. + */ + @WithDefault("false") + boolean imageProxyEnabled(); + + /** + * Enable password recovery via email. + * + * Quarkus mailer will need to be configured. + */ + @WithDefault("false") + boolean passwordRecoveryEnabled(); + + /** + * Message displayed in a notification at the bottom of the page. + */ + Optional announcement(); + + /** + * Google Analytics tracking code. + */ + Optional googleAnalyticsTrackingCode(); + + /** + * Google Auth key for fetching Youtube channel favicons. + */ + Optional googleAuthKey(); + + /** + * HTTP client configuration + */ + @ConfigDocSection + HttpClient httpClient(); + + /** + * Feed refresh engine settings. + */ + @ConfigDocSection + FeedRefresh feedRefresh(); + + /** + * Database settings. + */ + @ConfigDocSection + Database database(); + + /** + * Users settings. + */ + @ConfigDocSection + Users users(); + + /** + * Websocket settings. + */ + @ConfigDocSection + Websocket websocket(); + + interface HttpClient { + /** + * User-Agent string that will be used by the http client, leave empty for the default one. + */ + Optional userAgent(); + + /** + * Time to wait for a connection to be established. + */ + @WithDefault("5s") + Duration connectTimeout(); + + /** + * Time to wait for SSL handshake to complete. + */ + @WithDefault("5s") + Duration sslHandshakeTimeout(); + + /** + * Time to wait between two packets before timeout. + */ + @WithDefault("10s") + Duration socketTimeout(); + + /** + * Time to wait for the full response to be received. + */ + @WithDefault("10s") + Duration responseTimeout(); + + /** + * Time to live for a connection in the pool. + */ + @WithDefault("30s") + Duration connectionTimeToLive(); + + /** + * Time between eviction runs for idle connections. + */ + @WithDefault("1m") + Duration idleConnectionsEvictionInterval(); + + /** + * If a feed is larger than this, it will be discarded to prevent memory issues while parsing the feed. + */ + @WithDefault("5M") + MemorySize maxResponseSize(); + + /** + * Prevent access to local addresses to mitigate server-side request forgery (SSRF) attacks, which could potentially expose internal + * resources. + * + * You may want to disable this if you subscribe to feeds that are only available on your local network and you trust all users of + * your CommaFeed instance. + */ + @WithDefault("true") + boolean blockLocalAddresses(); + + /** + * HTTP client cache configuration + */ + @ConfigDocSection + HttpClientCache cache(); + } + + 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 + * first time or when clicking "fetch all my feeds now"). + */ + @WithDefault("true") + boolean enabled(); + + /** + * Maximum amount of memory the cache can use. + */ + @WithDefault("10M") + MemorySize maximumMemorySize(); + + /** + * Duration after which an entry is removed from the cache. + */ + @WithDefault("1m") + Duration expiration(); + } + + interface FeedRefresh { + /** + * Default amount of time CommaFeed will wait before refreshing a feed. + */ + @WithDefault("5m") + Duration interval(); + + /** + * Maximum amount of time CommaFeed will wait before refreshing a feed. This is used as an upper bound when: + * + *
    + *
  • an error occurs while refreshing a feed and we're backing off exponentially
  • + *
  • we receive a Cache-Control header from the feed
  • + *
  • we receive a Retry-After header from the feed
  • + *
+ */ + @WithDefault("4h") + Duration maxInterval(); + + /** + * If enabled, CommaFeed will calculate the next refresh time based on the feed's average time between entries and the time since + * the last entry was published. The interval will be sometimes between the default refresh interval + * (`commafeed.feed-refresh.interval`) and the maximum refresh interval (`commafeed.feed-refresh.max-interval`). + * + * See {@link FeedRefreshIntervalCalculator} for details. + */ + @WithDefault("true") + boolean intervalEmpirical(); + + /** + * Feed refresh engine error handling settings. + */ + @ConfigDocSection + FeedRefreshErrorHandling errors(); + + /** + * Amount of http threads used to fetch feeds. + */ + @Min(1) + @WithDefault("3") + int httpThreads(); + + /** + * Amount of threads used to insert new entries in the database. + */ + @Min(1) + @WithDefault("1") + int databaseThreads(); + + /** + * Duration after which a user is considered inactive. Feeds for inactive users are not refreshed until they log in again. + * + * 0 to disable. + */ + @WithDefault("0") + Duration userInactivityPeriod(); + + /** + * Duration after which the evaluation of a filtering expresion to mark an entry as read is considered to have timed out. + */ + @WithDefault("500ms") + Duration filteringExpressionEvaluationTimeout(); + + /** + * Duration after which the "Fetch all my feeds now" action is available again after use to avoid spamming feeds. + */ + @WithDefault("0") + Duration forceRefreshCooldownDuration(); + } + + interface FeedRefreshErrorHandling { + /** + * Number of retries before backoff is applied. + */ + @Min(0) + @WithDefault("3") + int retriesBeforeBackoff(); + + /** + * Duration to wait before retrying after an error. Will be multiplied by the number of errors since the last successful fetch. + */ + @WithDefault("1h") + Duration backoffInterval(); + } + + interface Database { + /** + * Timeout applied to all database queries. + * + * 0 to disable. + */ + @WithDefault("0") + Duration queryTimeout(); + + /** + * Database cleanup settings. + */ + @ConfigDocSection + Cleanup cleanup(); + + interface Cleanup { + /** + * Maximum age of feed entries in the database. Older entries will be deleted. + * + * 0 to disable. + */ + @WithDefault("365d") + Duration entriesMaxAge(); + + /** + * Maximum age of feed entry statuses (read/unread) in the database. Older statuses will be deleted. + * + * 0 to disable. + */ + @WithDefault("0") + Duration statusesMaxAge(); + + /** + * Maximum number of entries per feed to keep in the database. + * + * 0 to disable. + */ + @WithDefault("500") + int maxFeedCapacity(); + + /** + * Limit the number of feeds a user can subscribe to. + * + * 0 to disable. + */ + @WithDefault("0") + int maxFeedsPerUser(); + + /** + * Rows to delete per query while cleaning up old entries. + */ + @Positive + @WithDefault("100") + int batchSize(); + + default Instant statusesInstantThreshold() { + return statusesMaxAge().toMillis() > 0 ? Instant.now().minus(statusesMaxAge()) : null; + } + } + } + + interface Users { + /** + * Whether to let users create accounts for themselves. + */ + @WithDefault("false") + boolean allowRegistrations(); + + /** + * Whether to enable strict password validation (1 uppercase char, 1 lowercase char, 1 digit, 1 special char). + */ + @WithDefault("true") + boolean strictPasswordPolicy(); + + /** + * Whether to create a demo account the first time the app starts. + */ + @WithDefault("false") + boolean createDemoAccount(); + } + + interface Websocket { + /** + * Enable websocket connection so the server can notify web clients that there are new entries for feeds. + */ + @WithDefault("true") + boolean enabled(); + + /** + * Interval at which the client will send a ping message on the websocket to keep the connection alive. + */ + @WithDefault("15m") + Duration pingInterval(); + + /** + * If the websocket connection is disabled or the connection is lost, the client will reload the feed tree at this interval. + */ + @WithDefault("30s") + Duration treeReloadInterval(); + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/CommaFeedProducers.java b/commafeed-server/src/main/java/com/commafeed/CommaFeedProducers.java index 372f760e..8dc10932 100644 --- a/commafeed-server/src/main/java/com/commafeed/CommaFeedProducers.java +++ b/commafeed-server/src/main/java/com/commafeed/CommaFeedProducers.java @@ -1,25 +1,25 @@ -package com.commafeed; - -import java.time.InstantSource; - -import jakarta.enterprise.inject.Produces; -import jakarta.inject.Singleton; - -import com.codahale.metrics.MetricRegistry; - -@Singleton -public class CommaFeedProducers { - - @Produces - @Singleton - public InstantSource instantSource() { - return InstantSource.system(); - } - - @Produces - @Singleton - public MetricRegistry metricRegistry() { - return new MetricRegistry(); - } - -} +package com.commafeed; + +import java.time.InstantSource; + +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Singleton; + +import com.codahale.metrics.MetricRegistry; + +@Singleton +public class CommaFeedProducers { + + @Produces + @Singleton + public InstantSource instantSource() { + return InstantSource.system(); + } + + @Produces + @Singleton + public MetricRegistry metricRegistry() { + return new MetricRegistry(); + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/CommaFeedVersion.java b/commafeed-server/src/main/java/com/commafeed/CommaFeedVersion.java index bd0afd4f..1e548f7a 100644 --- a/commafeed-server/src/main/java/com/commafeed/CommaFeedVersion.java +++ b/commafeed-server/src/main/java/com/commafeed/CommaFeedVersion.java @@ -1,32 +1,32 @@ -package com.commafeed; - -import java.io.IOException; -import java.io.InputStream; -import java.util.Properties; - -import jakarta.inject.Singleton; - -import lombok.Getter; - -@Singleton -@Getter -public class CommaFeedVersion { - - private final String version; - private final String gitCommit; - - public CommaFeedVersion() { - Properties properties = new Properties(); - try (InputStream stream = getClass().getResourceAsStream("/git.properties")) { - if (stream != null) { - properties.load(stream); - } - } catch (IOException e) { - throw new RuntimeException(e); - } - - this.version = properties.getProperty("git.build.version", "unknown"); - this.gitCommit = properties.getProperty("git.commit.id.abbrev", "unknown"); - } - -} +package com.commafeed; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +import jakarta.inject.Singleton; + +import lombok.Getter; + +@Singleton +@Getter +public class CommaFeedVersion { + + private final String version; + private final String gitCommit; + + public CommaFeedVersion() { + Properties properties = new Properties(); + try (InputStream stream = getClass().getResourceAsStream("/git.properties")) { + if (stream != null) { + properties.load(stream); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + + this.version = properties.getProperty("git.build.version", "unknown"); + this.gitCommit = properties.getProperty("git.commit.id.abbrev", "unknown"); + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/ExceptionMappers.java b/commafeed-server/src/main/java/com/commafeed/ExceptionMappers.java index 5e03c413..4e47eec4 100644 --- a/commafeed-server/src/main/java/com/commafeed/ExceptionMappers.java +++ b/commafeed-server/src/main/java/com/commafeed/ExceptionMappers.java @@ -1,50 +1,50 @@ -package com.commafeed; - -import jakarta.annotation.Priority; -import jakarta.validation.ValidationException; -import jakarta.ws.rs.ext.Provider; - -import org.jboss.resteasy.reactive.RestResponse; -import org.jboss.resteasy.reactive.RestResponse.Status; -import org.jboss.resteasy.reactive.server.ServerExceptionMapper; - -import io.quarkus.runtime.annotations.RegisterForReflection; -import io.quarkus.security.AuthenticationFailedException; -import io.quarkus.security.UnauthorizedException; -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Provider -@Priority(1) -public class ExceptionMappers { - - private final CommaFeedConfiguration config; - - @ServerExceptionMapper(UnauthorizedException.class) - public RestResponse unauthorized(UnauthorizedException e) { - return RestResponse.status(RestResponse.Status.UNAUTHORIZED, - new UnauthorizedResponse(e.getMessage(), config.users().allowRegistrations())); - } - - @ServerExceptionMapper(AuthenticationFailedException.class) - public RestResponse authenticationFailed(AuthenticationFailedException e) { - return RestResponse.status(RestResponse.Status.UNAUTHORIZED, new AuthenticationFailed(e.getMessage())); - } - - @ServerExceptionMapper(ValidationException.class) - public RestResponse validationFailed(ValidationException e) { - return RestResponse.status(Status.BAD_REQUEST, new ValidationFailed(e.getMessage())); - } - - @RegisterForReflection - public record UnauthorizedResponse(String message, boolean allowRegistrations) { - } - - @RegisterForReflection - public record AuthenticationFailed(String message) { - } - - @RegisterForReflection - public record ValidationFailed(String message) { - } -} +package com.commafeed; + +import jakarta.annotation.Priority; +import jakarta.validation.ValidationException; +import jakarta.ws.rs.ext.Provider; + +import org.jboss.resteasy.reactive.RestResponse; +import org.jboss.resteasy.reactive.RestResponse.Status; +import org.jboss.resteasy.reactive.server.ServerExceptionMapper; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.UnauthorizedException; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Provider +@Priority(1) +public class ExceptionMappers { + + private final CommaFeedConfiguration config; + + @ServerExceptionMapper(UnauthorizedException.class) + public RestResponse unauthorized(UnauthorizedException e) { + return RestResponse.status(RestResponse.Status.UNAUTHORIZED, + new UnauthorizedResponse(e.getMessage(), config.users().allowRegistrations())); + } + + @ServerExceptionMapper(AuthenticationFailedException.class) + public RestResponse authenticationFailed(AuthenticationFailedException e) { + return RestResponse.status(RestResponse.Status.UNAUTHORIZED, new AuthenticationFailed(e.getMessage())); + } + + @ServerExceptionMapper(ValidationException.class) + public RestResponse validationFailed(ValidationException e) { + return RestResponse.status(Status.BAD_REQUEST, new ValidationFailed(e.getMessage())); + } + + @RegisterForReflection + public record UnauthorizedResponse(String message, boolean allowRegistrations) { + } + + @RegisterForReflection + public record AuthenticationFailed(String message) { + } + + @RegisterForReflection + public record ValidationFailed(String message) { + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/JacksonCustomizer.java b/commafeed-server/src/main/java/com/commafeed/JacksonCustomizer.java index e0bdef39..fb80944e 100644 --- a/commafeed-server/src/main/java/com/commafeed/JacksonCustomizer.java +++ b/commafeed-server/src/main/java/com/commafeed/JacksonCustomizer.java @@ -1,29 +1,29 @@ -package com.commafeed; - -import java.util.concurrent.TimeUnit; - -import jakarta.inject.Singleton; - -import com.codahale.metrics.json.MetricsModule; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; - -import io.quarkus.jackson.ObjectMapperCustomizer; - -@Singleton -public class JacksonCustomizer implements ObjectMapperCustomizer { - @Override - public void customize(ObjectMapper objectMapper) { - objectMapper.registerModule(new JavaTimeModule()); - - // read and write instants as milliseconds instead of nanoseconds - objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true) - .configure(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS, false) - .configure(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS, false); - - // add support for serializing metrics - objectMapper.registerModule(new MetricsModule(TimeUnit.SECONDS, TimeUnit.SECONDS, false)); - } -} +package com.commafeed; + +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Singleton; + +import com.codahale.metrics.json.MetricsModule; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import io.quarkus.jackson.ObjectMapperCustomizer; + +@Singleton +public class JacksonCustomizer implements ObjectMapperCustomizer { + @Override + public void customize(ObjectMapper objectMapper) { + objectMapper.registerModule(new JavaTimeModule()); + + // read and write instants as milliseconds instead of nanoseconds + objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true) + .configure(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS, false) + .configure(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS, false); + + // add support for serializing metrics + objectMapper.registerModule(new MetricsModule(TimeUnit.SECONDS, TimeUnit.SECONDS, false)); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/NativeImageClasses.java b/commafeed-server/src/main/java/com/commafeed/NativeImageClasses.java index 278f83e3..3bfc9df9 100644 --- a/commafeed-server/src/main/java/com/commafeed/NativeImageClasses.java +++ b/commafeed-server/src/main/java/com/commafeed/NativeImageClasses.java @@ -1,226 +1,226 @@ -package com.commafeed; - -import com.codahale.metrics.Counter; -import com.codahale.metrics.Gauge; -import com.codahale.metrics.Histogram; -import com.codahale.metrics.Meter; -import com.codahale.metrics.MetricRegistry; -import com.codahale.metrics.Timer; - -import io.quarkus.runtime.annotations.RegisterForReflection; - -@RegisterForReflection( - targets = { - // metrics - MetricRegistry.class, Meter.class, Gauge.class, Counter.class, Timer.class, Histogram.class, - - // rome - 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.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.SyndContentImpl.class, com.rometools.rome.feed.synd.SyndEnclosureImpl.class, - - // rome cloneable - 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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.Source.class, com.rometools.rome.feed.rss.TextInput.class, - com.rometools.rome.feed.synd.SyndLinkImpl.class, com.rometools.rome.feed.synd.SyndPersonImpl.class, - java.util.ArrayList.class, - - // rome modules - 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.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.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.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.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.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.base.GoogleBaseImpl.class, com.rometools.modules.sle.SleEntryImpl.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.mediarss.MediaModuleImpl.class, com.rometools.rome.feed.module.SyModuleImpl.class, - - // 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.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.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.Atom03Parser.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.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.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.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.ConverterForRSS091Netscape.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.ConverterForRSS094.class, com.rometools.rome.feed.synd.impl.ConverterForRSS10.class, - com.rometools.rome.feed.synd.impl.ConverterForRSS20.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.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.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.itunes.io.ITunesParserOldNamespace.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.fyyd.io.FyydParser.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.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.mediarss.io.MediaModuleParser.class, com.rometools.modules.atom.io.AtomModuleParser.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.yahooweather.io.WeatherModuleParser.class, com.rometools.modules.feedpress.io.FeedpressParser.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.ModuleParserRSS2.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.mediarss.io.MediaModuleParser.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.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.mediarss.io.AlternateMediaModuleParser.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.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.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.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.yahooweather.io.WeatherModuleParser.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.base.io.CustomTagParser.class, com.rometools.modules.content.io.ContentModuleParser.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.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.georss.W3CGeoParser.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.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.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.mediarss.io.MediaModuleParser.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.cc.io.CCModuleGenerator.class, com.rometools.modules.content.io.ContentModuleGenerator.class, - com.rometools.modules.itunes.io.ITunesGenerator.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.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.feedpress.io.FeedpressGenerator.class, com.rometools.modules.fyyd.io.FyydGenerator.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.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.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.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.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.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.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.yahooweather.io.WeatherModuleGenerator.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.slash.io.SlashModuleGenerator.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.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.mediarss.io.MediaModuleGenerator.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.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.mediarss.io.MediaModuleGenerator.class, com.rometools.modules.thr.io.ThreadingModuleGenerator.class, - com.rometools.modules.psc.io.PodloveSimpleChapterGenerator.class, - - com.rometools.modules.mediarss.io.MediaModuleParser.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.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, }) - -public class NativeImageClasses { -} +package com.commafeed; + +import com.codahale.metrics.Counter; +import com.codahale.metrics.Gauge; +import com.codahale.metrics.Histogram; +import com.codahale.metrics.Meter; +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection( + targets = { + // metrics + MetricRegistry.class, Meter.class, Gauge.class, Counter.class, Timer.class, Histogram.class, + + // rome + 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.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.SyndContentImpl.class, com.rometools.rome.feed.synd.SyndEnclosureImpl.class, + + // rome cloneable + 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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.Source.class, com.rometools.rome.feed.rss.TextInput.class, + com.rometools.rome.feed.synd.SyndLinkImpl.class, com.rometools.rome.feed.synd.SyndPersonImpl.class, + java.util.ArrayList.class, + + // rome modules + 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.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.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.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.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.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.base.GoogleBaseImpl.class, com.rometools.modules.sle.SleEntryImpl.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.mediarss.MediaModuleImpl.class, com.rometools.rome.feed.module.SyModuleImpl.class, + + // 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.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.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.Atom03Parser.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.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.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.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.ConverterForRSS091Netscape.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.ConverterForRSS094.class, com.rometools.rome.feed.synd.impl.ConverterForRSS10.class, + com.rometools.rome.feed.synd.impl.ConverterForRSS20.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.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.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.itunes.io.ITunesParserOldNamespace.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.fyyd.io.FyydParser.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.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.mediarss.io.MediaModuleParser.class, com.rometools.modules.atom.io.AtomModuleParser.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.yahooweather.io.WeatherModuleParser.class, com.rometools.modules.feedpress.io.FeedpressParser.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.ModuleParserRSS2.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.mediarss.io.MediaModuleParser.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.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.mediarss.io.AlternateMediaModuleParser.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.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.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.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.yahooweather.io.WeatherModuleParser.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.base.io.CustomTagParser.class, com.rometools.modules.content.io.ContentModuleParser.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.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.georss.W3CGeoParser.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.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.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.mediarss.io.MediaModuleParser.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.cc.io.CCModuleGenerator.class, com.rometools.modules.content.io.ContentModuleGenerator.class, + com.rometools.modules.itunes.io.ITunesGenerator.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.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.feedpress.io.FeedpressGenerator.class, com.rometools.modules.fyyd.io.FyydGenerator.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.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.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.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.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.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.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.yahooweather.io.WeatherModuleGenerator.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.slash.io.SlashModuleGenerator.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.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.mediarss.io.MediaModuleGenerator.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.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.mediarss.io.MediaModuleGenerator.class, com.rometools.modules.thr.io.ThreadingModuleGenerator.class, + com.rometools.modules.psc.io.PodloveSimpleChapterGenerator.class, + + com.rometools.modules.mediarss.io.MediaModuleParser.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.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, }) + +public class NativeImageClasses { +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/Digests.java b/commafeed-server/src/main/java/com/commafeed/backend/Digests.java index 09369a14..86819e81 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/Digests.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/Digests.java @@ -1,29 +1,29 @@ -package com.commafeed.backend; - -import java.nio.charset.StandardCharsets; - -import com.google.common.hash.HashFunction; -import com.google.common.hash.Hashing; - -import lombok.experimental.UtilityClass; - -@UtilityClass -@SuppressWarnings("deprecation") -public class Digests { - - public static String sha1Hex(byte[] input) { - return hashBytesToHex(Hashing.sha1(), input); - } - - public static String sha1Hex(String input) { - return hashBytesToHex(Hashing.sha1(), input.getBytes(StandardCharsets.UTF_8)); - } - - public static String md5Hex(String input) { - return hashBytesToHex(Hashing.md5(), input.getBytes(StandardCharsets.UTF_8)); - } - - private static String hashBytesToHex(HashFunction function, byte[] input) { - return function.hashBytes(input).toString(); - } -} +package com.commafeed.backend; + +import java.nio.charset.StandardCharsets; + +import com.google.common.hash.HashFunction; +import com.google.common.hash.Hashing; + +import lombok.experimental.UtilityClass; + +@UtilityClass +@SuppressWarnings("deprecation") +public class Digests { + + public static String sha1Hex(byte[] input) { + return hashBytesToHex(Hashing.sha1(), input); + } + + public static String sha1Hex(String input) { + return hashBytesToHex(Hashing.sha1(), input.getBytes(StandardCharsets.UTF_8)); + } + + public static String md5Hex(String input) { + return hashBytesToHex(Hashing.md5(), input.getBytes(StandardCharsets.UTF_8)); + } + + private static String hashBytesToHex(HashFunction function, byte[] input) { + return function.hashBytes(input).toString(); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/HttpGetter.java b/commafeed-server/src/main/java/com/commafeed/backend/HttpGetter.java index 914b8af4..8a49044b 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/HttpGetter.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/HttpGetter.java @@ -1,428 +1,428 @@ -package com.commafeed.backend; - -import java.io.IOException; -import java.io.InputStream; -import java.net.InetAddress; -import java.net.URI; -import java.net.UnknownHostException; -import java.time.Duration; -import java.time.Instant; -import java.time.InstantSource; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.ExecutionException; -import java.util.stream.Stream; - -import jakarta.inject.Singleton; -import jakarta.ws.rs.core.CacheControl; - -import org.apache.commons.lang3.StringUtils; -import org.apache.hc.client5.http.DnsResolver; -import org.apache.hc.client5.http.SystemDefaultDnsResolver; -import org.apache.hc.client5.http.config.ConnectionConfig; -import org.apache.hc.client5.http.config.RequestConfig; -import org.apache.hc.client5.http.config.TlsConfig; -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; -import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; -import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; -import org.apache.hc.client5.http.io.HttpClientConnectionManager; -import org.apache.hc.client5.http.protocol.HttpClientContext; -import org.apache.hc.client5.http.protocol.RedirectLocations; -import org.apache.hc.client5.http.utils.DateUtils; -import org.apache.hc.core5.http.ClassicHttpRequest; -import org.apache.hc.core5.http.Header; -import org.apache.hc.core5.http.HttpEntity; -import org.apache.hc.core5.http.HttpStatus; -import org.apache.hc.core5.http.NameValuePair; -import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; -import org.apache.hc.core5.http.message.BasicHeader; -import org.apache.hc.core5.util.TimeValue; -import org.apache.hc.core5.util.Timeout; -import org.jboss.resteasy.reactive.common.headers.CacheControlDelegate; - -import com.codahale.metrics.MetricRegistry; -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.CommaFeedConfiguration.HttpClientCache; -import com.commafeed.CommaFeedVersion; -import com.google.common.cache.Cache; -import com.google.common.cache.CacheBuilder; -import com.google.common.collect.Iterables; -import com.google.common.io.ByteStreams; -import com.google.common.net.HttpHeaders; - -import lombok.Builder; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import lombok.Value; -import lombok.extern.slf4j.Slf4j; -import nl.altindag.ssl.SSLFactory; -import nl.altindag.ssl.apache5.util.Apache5SslUtils; - -/** - * Smart HTTP getter: handles gzip, ssl, last modified and etag headers - */ -@Singleton -@Slf4j -public class HttpGetter { - - private final CommaFeedConfiguration config; - private final InstantSource instantSource; - private final CloseableHttpClient client; - private final Cache cache; - private final DnsResolver dnsResolver = SystemDefaultDnsResolver.INSTANCE; - - public HttpGetter(CommaFeedConfiguration config, InstantSource instantSource, CommaFeedVersion version, MetricRegistry metrics) { - this.config = config; - this.instantSource = instantSource; - - PoolingHttpClientConnectionManager connectionManager = newConnectionManager(config); - String userAgent = config.httpClient() - .userAgent() - .orElseGet(() -> String.format("CommaFeed/%s (https://github.com/Athou/commafeed)", version.getVersion())); - - this.client = newClient(connectionManager, userAgent, config.httpClient().idleConnectionsEvictionInterval()); - this.cache = newCache(config); - - metrics.registerGauge(MetricRegistry.name(getClass(), "pool", "max"), () -> connectionManager.getTotalStats().getMax()); - metrics.registerGauge(MetricRegistry.name(getClass(), "pool", "size"), - () -> connectionManager.getTotalStats().getAvailable() + 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(), "cache", "size"), () -> cache == null ? 0 : cache.size()); - metrics.registerGauge(MetricRegistry.name(getClass(), "cache", "memoryUsage"), - () -> cache == null ? 0 : cache.asMap().values().stream().mapToInt(e -> e.content != null ? e.content.length : 0).sum()); - } - - public HttpResult get(String url) - throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException, HostNotAllowedException { - return get(HttpRequest.builder(url).build()); - } - - public HttpResult get(HttpRequest request) - throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException, HostNotAllowedException { - URI uri = URI.create(request.getUrl()); - ensureHttpScheme(uri.getScheme()); - - if (config.httpClient().blockLocalAddresses()) { - ensurePublicAddress(uri.getHost()); - } - - final HttpResponse response; - if (cache == null) { - response = invoke(request); - } else { - try { - response = cache.get(request, () -> invoke(request)); - } catch (ExecutionException e) { - if (e.getCause() instanceof IOException ioe) { - throw ioe; - } else { - throw new RuntimeException(e); - } - } - } - - int code = response.getCode(); - if (code == HttpStatus.SC_TOO_MANY_REQUESTS || code == HttpStatus.SC_SERVICE_UNAVAILABLE && response.getRetryAfter() != null) { - throw new TooManyRequestsException(response.getRetryAfter()); - } - - if (code == HttpStatus.SC_NOT_MODIFIED) { - throw new NotModifiedException("'304 - not modified' http code received"); - } - - if (code >= 300) { - throw new HttpResponseException(code, "Server returned HTTP error code " + code); - } - - String lastModifiedHeader = response.getLastModifiedHeader(); - if (lastModifiedHeader != null && lastModifiedHeader.equals(request.getLastModified())) { - throw new NotModifiedException("lastModifiedHeader is the same"); - } - - String eTagHeader = response.getETagHeader(); - if (eTagHeader != null && eTagHeader.equals(request.getETag())) { - throw new NotModifiedException("eTagHeader is the same"); - } - - Duration validFor = Optional.ofNullable(response.getCacheControl()) - .filter(cc -> cc.getMaxAge() >= 0) - .map(cc -> Duration.ofSeconds(cc.getMaxAge())) - .orElse(Duration.ZERO); - - return new HttpResult(response.getContent(), response.getContentType(), lastModifiedHeader, eTagHeader, - response.getUrlAfterRedirect(), validFor); - } - - private void ensureHttpScheme(String scheme) throws SchemeNotAllowedException { - if (!"http".equals(scheme) && !"https".equals(scheme)) { - throw new SchemeNotAllowedException(scheme); - } - } - - private void ensurePublicAddress(String host) throws HostNotAllowedException, UnknownHostException { - if (host == null) { - throw new HostNotAllowedException(null); - } - - InetAddress[] addresses = dnsResolver.resolve(host); - if (Stream.of(addresses).anyMatch(this::isPrivateAddress)) { - throw new HostNotAllowedException(host); - } - } - - private boolean isPrivateAddress(InetAddress address) { - return address.isSiteLocalAddress() || address.isAnyLocalAddress() || address.isLinkLocalAddress() || address.isLoopbackAddress() - || address.isMulticastAddress(); - } - - private HttpResponse invoke(HttpRequest request) throws IOException { - log.debug("fetching {}", request.getUrl()); - - HttpClientContext context = HttpClientContext.create(); - context.setRequestConfig(RequestConfig.custom() - .setResponseTimeout(Timeout.of(config.httpClient().responseTimeout())) - // causes issues with some feeds - // see https://github.com/Athou/commafeed/issues/1572 - // and https://issues.apache.org/jira/browse/HTTPCLIENT-2344 - .setProtocolUpgradeEnabled(false) - .build()); - - return client.execute(request.toClassicHttpRequest(), context, resp -> { - byte[] content = resp.getEntity() == null ? null - : toByteArray(resp.getEntity(), config.httpClient().maxResponseSize().asLongValue()); - int code = resp.getCode(); - String lastModifiedHeader = Optional.ofNullable(resp.getFirstHeader(HttpHeaders.LAST_MODIFIED)) - .map(NameValuePair::getValue) - .map(StringUtils::trimToNull) - .orElse(null); - String eTagHeader = Optional.ofNullable(resp.getFirstHeader(HttpHeaders.ETAG)) - .map(NameValuePair::getValue) - .map(StringUtils::trimToNull) - .orElse(null); - - CacheControl cacheControl = Optional.ofNullable(resp.getFirstHeader(HttpHeaders.CACHE_CONTROL)) - .map(NameValuePair::getValue) - .map(StringUtils::trimToNull) - .map(HttpGetter::toCacheControl) - .orElse(null); - - Instant retryAfter = Optional.ofNullable(resp.getFirstHeader(HttpHeaders.RETRY_AFTER)) - .map(NameValuePair::getValue) - .map(StringUtils::trimToNull) - .map(this::toInstant) - .orElse(null); - - String contentType = Optional.ofNullable(resp.getEntity()).map(HttpEntity::getContentType).orElse(null); - String urlAfterRedirect = Optional.ofNullable(context.getRedirectLocations()) - .map(RedirectLocations::getAll) - .map(l -> Iterables.getLast(l, null)) - .map(URI::toString) - .orElse(request.getUrl()); - - return new HttpResponse(code, lastModifiedHeader, eTagHeader, cacheControl, retryAfter, content, contentType, urlAfterRedirect); - }); - } - - private static CacheControl toCacheControl(String headerValue) { - try { - return CacheControlDelegate.INSTANCE.fromString(headerValue); - } catch (Exception e) { - log.debug("Invalid Cache-Control header: {}", headerValue); - return null; - } - } - - private Instant toInstant(String headerValue) { - if (headerValue == null) { - return null; - } - - if (StringUtils.isNumeric(headerValue)) { - return instantSource.instant().plusSeconds(Long.parseLong(headerValue)); - } - - return DateUtils.parseStandardDate(headerValue); - } - - private static byte[] toByteArray(HttpEntity entity, long maxBytes) throws IOException { - if (entity.getContentLength() > maxBytes) { - throw new IOException( - "Response size (%s bytes) exceeds the maximum allowed size (%s bytes)".formatted(entity.getContentLength(), maxBytes)); - } - - try (InputStream input = entity.getContent()) { - if (input == null) { - return null; - } - - byte[] bytes = ByteStreams.limit(input, maxBytes).readAllBytes(); - if (bytes.length == maxBytes) { - throw new IOException("Response size exceeds the maximum allowed size (%s bytes)".formatted(maxBytes)); - } - return bytes; - } - } - - private PoolingHttpClientConnectionManager newConnectionManager(CommaFeedConfiguration config) { - SSLFactory sslFactory = SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build(); - - int poolSize = config.feedRefresh().httpThreads(); - return PoolingHttpClientConnectionManagerBuilder.create() - .setTlsSocketStrategy(Apache5SslUtils.toTlsSocketStrategy(sslFactory)) - .setDefaultConnectionConfig(ConnectionConfig.custom() - .setConnectTimeout(Timeout.of(config.httpClient().connectTimeout())) - .setSocketTimeout(Timeout.of(config.httpClient().socketTimeout())) - .setTimeToLive(Timeout.of(config.httpClient().connectionTimeToLive())) - .build()) - .setDefaultTlsConfig(TlsConfig.custom().setHandshakeTimeout(Timeout.of(config.httpClient().sslHandshakeTimeout())).build()) - .setMaxConnPerRoute(poolSize) - .setMaxConnTotal(poolSize) - .setDnsResolver(dnsResolver) - .build(); - - } - - private static CloseableHttpClient newClient(HttpClientConnectionManager connectionManager, String userAgent, - Duration idleConnectionsEvictionInterval) { - List
headers = new ArrayList<>(); - headers.add(new BasicHeader(HttpHeaders.ACCEPT_LANGUAGE, "en")); - headers.add(new BasicHeader(HttpHeaders.PRAGMA, "No-cache")); - headers.add(new BasicHeader(HttpHeaders.CACHE_CONTROL, "no-cache")); - - return HttpClientBuilder.create() - .useSystemProperties() - .disableAutomaticRetries() - .disableCookieManagement() - .setUserAgent(userAgent) - .setDefaultHeaders(headers) - .setConnectionManager(connectionManager) - .evictExpiredConnections() - .evictIdleConnections(TimeValue.of(idleConnectionsEvictionInterval)) - .build(); - } - - private static Cache newCache(CommaFeedConfiguration config) { - HttpClientCache cacheConfig = config.httpClient().cache(); - if (!cacheConfig.enabled()) { - return null; - } - - return CacheBuilder.newBuilder() - .weigher((HttpRequest key, HttpResponse value) -> value.getContent() != null ? value.getContent().length : 0) - .maximumWeight(cacheConfig.maximumMemorySize().asLongValue()) - .expireAfterWrite(cacheConfig.expiration()) - .build(); - } - - public static class SchemeNotAllowedException extends Exception { - private static final long serialVersionUID = 1L; - - public SchemeNotAllowedException(String scheme) { - super("Scheme not allowed: " + scheme); - } - } - - public static class HostNotAllowedException extends Exception { - private static final long serialVersionUID = 1L; - - public HostNotAllowedException(String host) { - super("Host not allowed: " + host); - } - } - - @Getter - public static class NotModifiedException extends Exception { - private static final long serialVersionUID = 1L; - - /** - * if the value of this header changed, this is its new value - */ - private final String newLastModifiedHeader; - - /** - * if the value of this header changed, this is its new value - */ - private final String newEtagHeader; - - public NotModifiedException(String message) { - this(message, null, null); - } - - public NotModifiedException(String message, String newLastModifiedHeader, String newEtagHeader) { - super(message); - this.newLastModifiedHeader = newLastModifiedHeader; - this.newEtagHeader = newEtagHeader; - } - } - - @RequiredArgsConstructor - @Getter - public static class TooManyRequestsException extends Exception { - private static final long serialVersionUID = 1L; - - private final Instant retryAfter; - } - - @Getter - public static class HttpResponseException extends IOException { - private static final long serialVersionUID = 1L; - - private final int code; - - public HttpResponseException(int code, String message) { - super(message); - this.code = code; - } - } - - @Builder(builderMethodName = "") - @EqualsAndHashCode - @Getter - public static class HttpRequest { - private String url; - private String lastModified; - private String eTag; - - public static HttpRequestBuilder builder(String url) { - return new HttpRequestBuilder().url(url); - } - - public ClassicHttpRequest toClassicHttpRequest() { - ClassicHttpRequest req = ClassicRequestBuilder.get(url).build(); - if (lastModified != null) { - req.addHeader(HttpHeaders.IF_MODIFIED_SINCE, lastModified); - } - if (eTag != null) { - req.addHeader(HttpHeaders.IF_NONE_MATCH, eTag); - } - return req; - } - } - - @Value - private static class HttpResponse { - int code; - String lastModifiedHeader; - String eTagHeader; - CacheControl cacheControl; - Instant retryAfter; - byte[] content; - String contentType; - String urlAfterRedirect; - } - - @Value - public static class HttpResult { - byte[] content; - String contentType; - String lastModifiedSince; - String eTag; - String urlAfterRedirect; - Duration validFor; - } - -} +package com.commafeed.backend; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; +import java.time.Duration; +import java.time.Instant; +import java.time.InstantSource; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.stream.Stream; + +import jakarta.inject.Singleton; +import jakarta.ws.rs.core.CacheControl; + +import org.apache.commons.lang3.StringUtils; +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.config.TlsConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.client5.http.protocol.RedirectLocations; +import org.apache.hc.client5.http.utils.DateUtils; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.NameValuePair; +import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; +import org.apache.hc.core5.http.message.BasicHeader; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; +import org.jboss.resteasy.reactive.common.headers.CacheControlDelegate; + +import com.codahale.metrics.MetricRegistry; +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.CommaFeedConfiguration.HttpClientCache; +import com.commafeed.CommaFeedVersion; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.common.collect.Iterables; +import com.google.common.io.ByteStreams; +import com.google.common.net.HttpHeaders; + +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Value; +import lombok.extern.slf4j.Slf4j; +import nl.altindag.ssl.SSLFactory; +import nl.altindag.ssl.apache5.util.Apache5SslUtils; + +/** + * Smart HTTP getter: handles gzip, ssl, last modified and etag headers + */ +@Singleton +@Slf4j +public class HttpGetter { + + private final CommaFeedConfiguration config; + private final InstantSource instantSource; + private final CloseableHttpClient client; + private final Cache cache; + private final DnsResolver dnsResolver = SystemDefaultDnsResolver.INSTANCE; + + public HttpGetter(CommaFeedConfiguration config, InstantSource instantSource, CommaFeedVersion version, MetricRegistry metrics) { + this.config = config; + this.instantSource = instantSource; + + PoolingHttpClientConnectionManager connectionManager = newConnectionManager(config); + String userAgent = config.httpClient() + .userAgent() + .orElseGet(() -> String.format("CommaFeed/%s (https://github.com/Athou/commafeed)", version.getVersion())); + + this.client = newClient(connectionManager, userAgent, config.httpClient().idleConnectionsEvictionInterval()); + this.cache = newCache(config); + + metrics.registerGauge(MetricRegistry.name(getClass(), "pool", "max"), () -> connectionManager.getTotalStats().getMax()); + metrics.registerGauge(MetricRegistry.name(getClass(), "pool", "size"), + () -> connectionManager.getTotalStats().getAvailable() + 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(), "cache", "size"), () -> cache == null ? 0 : cache.size()); + metrics.registerGauge(MetricRegistry.name(getClass(), "cache", "memoryUsage"), + () -> cache == null ? 0 : cache.asMap().values().stream().mapToInt(e -> e.content != null ? e.content.length : 0).sum()); + } + + public HttpResult get(String url) + throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException, HostNotAllowedException { + return get(HttpRequest.builder(url).build()); + } + + public HttpResult get(HttpRequest request) + throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException, HostNotAllowedException { + URI uri = URI.create(request.getUrl()); + ensureHttpScheme(uri.getScheme()); + + if (config.httpClient().blockLocalAddresses()) { + ensurePublicAddress(uri.getHost()); + } + + final HttpResponse response; + if (cache == null) { + response = invoke(request); + } else { + try { + response = cache.get(request, () -> invoke(request)); + } catch (ExecutionException e) { + if (e.getCause() instanceof IOException ioe) { + throw ioe; + } else { + throw new RuntimeException(e); + } + } + } + + int code = response.getCode(); + if (code == HttpStatus.SC_TOO_MANY_REQUESTS || code == HttpStatus.SC_SERVICE_UNAVAILABLE && response.getRetryAfter() != null) { + throw new TooManyRequestsException(response.getRetryAfter()); + } + + if (code == HttpStatus.SC_NOT_MODIFIED) { + throw new NotModifiedException("'304 - not modified' http code received"); + } + + if (code >= 300) { + throw new HttpResponseException(code, "Server returned HTTP error code " + code); + } + + String lastModifiedHeader = response.getLastModifiedHeader(); + if (lastModifiedHeader != null && lastModifiedHeader.equals(request.getLastModified())) { + throw new NotModifiedException("lastModifiedHeader is the same"); + } + + String eTagHeader = response.getETagHeader(); + if (eTagHeader != null && eTagHeader.equals(request.getETag())) { + throw new NotModifiedException("eTagHeader is the same"); + } + + Duration validFor = Optional.ofNullable(response.getCacheControl()) + .filter(cc -> cc.getMaxAge() >= 0) + .map(cc -> Duration.ofSeconds(cc.getMaxAge())) + .orElse(Duration.ZERO); + + return new HttpResult(response.getContent(), response.getContentType(), lastModifiedHeader, eTagHeader, + response.getUrlAfterRedirect(), validFor); + } + + private void ensureHttpScheme(String scheme) throws SchemeNotAllowedException { + if (!"http".equals(scheme) && !"https".equals(scheme)) { + throw new SchemeNotAllowedException(scheme); + } + } + + private void ensurePublicAddress(String host) throws HostNotAllowedException, UnknownHostException { + if (host == null) { + throw new HostNotAllowedException(null); + } + + InetAddress[] addresses = dnsResolver.resolve(host); + if (Stream.of(addresses).anyMatch(this::isPrivateAddress)) { + throw new HostNotAllowedException(host); + } + } + + private boolean isPrivateAddress(InetAddress address) { + return address.isSiteLocalAddress() || address.isAnyLocalAddress() || address.isLinkLocalAddress() || address.isLoopbackAddress() + || address.isMulticastAddress(); + } + + private HttpResponse invoke(HttpRequest request) throws IOException { + log.debug("fetching {}", request.getUrl()); + + HttpClientContext context = HttpClientContext.create(); + context.setRequestConfig(RequestConfig.custom() + .setResponseTimeout(Timeout.of(config.httpClient().responseTimeout())) + // causes issues with some feeds + // see https://github.com/Athou/commafeed/issues/1572 + // and https://issues.apache.org/jira/browse/HTTPCLIENT-2344 + .setProtocolUpgradeEnabled(false) + .build()); + + return client.execute(request.toClassicHttpRequest(), context, resp -> { + byte[] content = resp.getEntity() == null ? null + : toByteArray(resp.getEntity(), config.httpClient().maxResponseSize().asLongValue()); + int code = resp.getCode(); + String lastModifiedHeader = Optional.ofNullable(resp.getFirstHeader(HttpHeaders.LAST_MODIFIED)) + .map(NameValuePair::getValue) + .map(StringUtils::trimToNull) + .orElse(null); + String eTagHeader = Optional.ofNullable(resp.getFirstHeader(HttpHeaders.ETAG)) + .map(NameValuePair::getValue) + .map(StringUtils::trimToNull) + .orElse(null); + + CacheControl cacheControl = Optional.ofNullable(resp.getFirstHeader(HttpHeaders.CACHE_CONTROL)) + .map(NameValuePair::getValue) + .map(StringUtils::trimToNull) + .map(HttpGetter::toCacheControl) + .orElse(null); + + Instant retryAfter = Optional.ofNullable(resp.getFirstHeader(HttpHeaders.RETRY_AFTER)) + .map(NameValuePair::getValue) + .map(StringUtils::trimToNull) + .map(this::toInstant) + .orElse(null); + + String contentType = Optional.ofNullable(resp.getEntity()).map(HttpEntity::getContentType).orElse(null); + String urlAfterRedirect = Optional.ofNullable(context.getRedirectLocations()) + .map(RedirectLocations::getAll) + .map(l -> Iterables.getLast(l, null)) + .map(URI::toString) + .orElse(request.getUrl()); + + return new HttpResponse(code, lastModifiedHeader, eTagHeader, cacheControl, retryAfter, content, contentType, urlAfterRedirect); + }); + } + + private static CacheControl toCacheControl(String headerValue) { + try { + return CacheControlDelegate.INSTANCE.fromString(headerValue); + } catch (Exception e) { + log.debug("Invalid Cache-Control header: {}", headerValue); + return null; + } + } + + private Instant toInstant(String headerValue) { + if (headerValue == null) { + return null; + } + + if (StringUtils.isNumeric(headerValue)) { + return instantSource.instant().plusSeconds(Long.parseLong(headerValue)); + } + + return DateUtils.parseStandardDate(headerValue); + } + + private static byte[] toByteArray(HttpEntity entity, long maxBytes) throws IOException { + if (entity.getContentLength() > maxBytes) { + throw new IOException( + "Response size (%s bytes) exceeds the maximum allowed size (%s bytes)".formatted(entity.getContentLength(), maxBytes)); + } + + try (InputStream input = entity.getContent()) { + if (input == null) { + return null; + } + + byte[] bytes = ByteStreams.limit(input, maxBytes).readAllBytes(); + if (bytes.length == maxBytes) { + throw new IOException("Response size exceeds the maximum allowed size (%s bytes)".formatted(maxBytes)); + } + return bytes; + } + } + + private PoolingHttpClientConnectionManager newConnectionManager(CommaFeedConfiguration config) { + SSLFactory sslFactory = SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build(); + + int poolSize = config.feedRefresh().httpThreads(); + return PoolingHttpClientConnectionManagerBuilder.create() + .setTlsSocketStrategy(Apache5SslUtils.toTlsSocketStrategy(sslFactory)) + .setDefaultConnectionConfig(ConnectionConfig.custom() + .setConnectTimeout(Timeout.of(config.httpClient().connectTimeout())) + .setSocketTimeout(Timeout.of(config.httpClient().socketTimeout())) + .setTimeToLive(Timeout.of(config.httpClient().connectionTimeToLive())) + .build()) + .setDefaultTlsConfig(TlsConfig.custom().setHandshakeTimeout(Timeout.of(config.httpClient().sslHandshakeTimeout())).build()) + .setMaxConnPerRoute(poolSize) + .setMaxConnTotal(poolSize) + .setDnsResolver(dnsResolver) + .build(); + + } + + private static CloseableHttpClient newClient(HttpClientConnectionManager connectionManager, String userAgent, + Duration idleConnectionsEvictionInterval) { + List
headers = new ArrayList<>(); + headers.add(new BasicHeader(HttpHeaders.ACCEPT_LANGUAGE, "en")); + headers.add(new BasicHeader(HttpHeaders.PRAGMA, "No-cache")); + headers.add(new BasicHeader(HttpHeaders.CACHE_CONTROL, "no-cache")); + + return HttpClientBuilder.create() + .useSystemProperties() + .disableAutomaticRetries() + .disableCookieManagement() + .setUserAgent(userAgent) + .setDefaultHeaders(headers) + .setConnectionManager(connectionManager) + .evictExpiredConnections() + .evictIdleConnections(TimeValue.of(idleConnectionsEvictionInterval)) + .build(); + } + + private static Cache newCache(CommaFeedConfiguration config) { + HttpClientCache cacheConfig = config.httpClient().cache(); + if (!cacheConfig.enabled()) { + return null; + } + + return CacheBuilder.newBuilder() + .weigher((HttpRequest key, HttpResponse value) -> value.getContent() != null ? value.getContent().length : 0) + .maximumWeight(cacheConfig.maximumMemorySize().asLongValue()) + .expireAfterWrite(cacheConfig.expiration()) + .build(); + } + + public static class SchemeNotAllowedException extends Exception { + private static final long serialVersionUID = 1L; + + public SchemeNotAllowedException(String scheme) { + super("Scheme not allowed: " + scheme); + } + } + + public static class HostNotAllowedException extends Exception { + private static final long serialVersionUID = 1L; + + public HostNotAllowedException(String host) { + super("Host not allowed: " + host); + } + } + + @Getter + public static class NotModifiedException extends Exception { + private static final long serialVersionUID = 1L; + + /** + * if the value of this header changed, this is its new value + */ + private final String newLastModifiedHeader; + + /** + * if the value of this header changed, this is its new value + */ + private final String newEtagHeader; + + public NotModifiedException(String message) { + this(message, null, null); + } + + public NotModifiedException(String message, String newLastModifiedHeader, String newEtagHeader) { + super(message); + this.newLastModifiedHeader = newLastModifiedHeader; + this.newEtagHeader = newEtagHeader; + } + } + + @RequiredArgsConstructor + @Getter + public static class TooManyRequestsException extends Exception { + private static final long serialVersionUID = 1L; + + private final Instant retryAfter; + } + + @Getter + public static class HttpResponseException extends IOException { + private static final long serialVersionUID = 1L; + + private final int code; + + public HttpResponseException(int code, String message) { + super(message); + this.code = code; + } + } + + @Builder(builderMethodName = "") + @EqualsAndHashCode + @Getter + public static class HttpRequest { + private String url; + private String lastModified; + private String eTag; + + public static HttpRequestBuilder builder(String url) { + return new HttpRequestBuilder().url(url); + } + + public ClassicHttpRequest toClassicHttpRequest() { + ClassicHttpRequest req = ClassicRequestBuilder.get(url).build(); + if (lastModified != null) { + req.addHeader(HttpHeaders.IF_MODIFIED_SINCE, lastModified); + } + if (eTag != null) { + req.addHeader(HttpHeaders.IF_NONE_MATCH, eTag); + } + return req; + } + } + + @Value + private static class HttpResponse { + int code; + String lastModifiedHeader; + String eTagHeader; + CacheControl cacheControl; + Instant retryAfter; + byte[] content; + String contentType; + String urlAfterRedirect; + } + + @Value + public static class HttpResult { + byte[] content; + String contentType; + String lastModifiedSince; + String eTag; + String urlAfterRedirect; + Duration validFor; + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedCategoryDAO.java b/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedCategoryDAO.java index 3465a93f..6659e776 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedCategoryDAO.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedCategoryDAO.java @@ -1,71 +1,71 @@ -package com.commafeed.backend.dao; - -import java.util.List; -import java.util.Objects; - -import jakarta.inject.Singleton; -import jakarta.persistence.EntityManager; - -import com.commafeed.backend.model.FeedCategory; -import com.commafeed.backend.model.QFeedCategory; -import com.commafeed.backend.model.QUser; -import com.commafeed.backend.model.User; -import com.querydsl.core.types.Predicate; - -@Singleton -public class FeedCategoryDAO extends GenericDAO { - - private static final QFeedCategory CATEGORY = QFeedCategory.feedCategory; - - public FeedCategoryDAO(EntityManager entityManager) { - super(entityManager, FeedCategory.class); - } - - public List findAll(User user) { - return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user)).join(CATEGORY.user, QUser.user).fetchJoin().fetch(); - } - - public FeedCategory findById(User user, Long id) { - return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user), CATEGORY.id.eq(id)).fetchOne(); - } - - public FeedCategory findByName(User user, String name, FeedCategory parent) { - Predicate parentPredicate; - if (parent == null) { - parentPredicate = CATEGORY.parent.isNull(); - } else { - parentPredicate = CATEGORY.parent.eq(parent); - } - return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user), CATEGORY.name.eq(name), parentPredicate).fetchOne(); - } - - public List findByParent(User user, FeedCategory parent) { - Predicate parentPredicate; - if (parent == null) { - parentPredicate = CATEGORY.parent.isNull(); - } else { - parentPredicate = CATEGORY.parent.eq(parent); - } - return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user), parentPredicate).fetch(); - } - - public List findAllChildrenCategories(User user, FeedCategory parent) { - return findAll(user).stream().filter(c -> isChild(c, parent)).toList(); - } - - private boolean isChild(FeedCategory child, FeedCategory parent) { - if (parent == null) { - return true; - } - boolean isChild = false; - while (child != null) { - if (Objects.equals(child.getId(), parent.getId())) { - isChild = true; - break; - } - child = child.getParent(); - } - return isChild; - } - -} +package com.commafeed.backend.dao; + +import java.util.List; +import java.util.Objects; + +import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; + +import com.commafeed.backend.model.FeedCategory; +import com.commafeed.backend.model.QFeedCategory; +import com.commafeed.backend.model.QUser; +import com.commafeed.backend.model.User; +import com.querydsl.core.types.Predicate; + +@Singleton +public class FeedCategoryDAO extends GenericDAO { + + private static final QFeedCategory CATEGORY = QFeedCategory.feedCategory; + + public FeedCategoryDAO(EntityManager entityManager) { + super(entityManager, FeedCategory.class); + } + + public List findAll(User user) { + return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user)).join(CATEGORY.user, QUser.user).fetchJoin().fetch(); + } + + public FeedCategory findById(User user, Long id) { + return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user), CATEGORY.id.eq(id)).fetchOne(); + } + + public FeedCategory findByName(User user, String name, FeedCategory parent) { + Predicate parentPredicate; + if (parent == null) { + parentPredicate = CATEGORY.parent.isNull(); + } else { + parentPredicate = CATEGORY.parent.eq(parent); + } + return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user), CATEGORY.name.eq(name), parentPredicate).fetchOne(); + } + + public List findByParent(User user, FeedCategory parent) { + Predicate parentPredicate; + if (parent == null) { + parentPredicate = CATEGORY.parent.isNull(); + } else { + parentPredicate = CATEGORY.parent.eq(parent); + } + return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user), parentPredicate).fetch(); + } + + public List findAllChildrenCategories(User user, FeedCategory parent) { + return findAll(user).stream().filter(c -> isChild(c, parent)).toList(); + } + + private boolean isChild(FeedCategory child, FeedCategory parent) { + if (parent == null) { + return true; + } + boolean isChild = false; + while (child != null) { + if (Objects.equals(child.getId(), parent.getId())) { + isChild = true; + break; + } + child = child.getParent(); + } + return isChild; + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedDAO.java b/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedDAO.java index f53c9340..67792183 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedDAO.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedDAO.java @@ -1,64 +1,64 @@ -package com.commafeed.backend.dao; - -import java.time.Instant; -import java.util.List; - -import jakarta.inject.Singleton; -import jakarta.persistence.EntityManager; - -import org.apache.commons.lang3.StringUtils; - -import com.commafeed.backend.model.Feed; -import com.commafeed.backend.model.QFeed; -import com.commafeed.backend.model.QFeedSubscription; -import com.querydsl.jpa.JPAExpressions; -import com.querydsl.jpa.impl.JPAQuery; - -@Singleton -public class FeedDAO extends GenericDAO { - - private static final QFeed FEED = QFeed.feed; - private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription; - - public FeedDAO(EntityManager entityManager) { - super(entityManager, Feed.class); - } - - public List findByIds(List id) { - return query().selectFrom(FEED).where(FEED.id.in(id)).fetch(); - } - - public List findNextUpdatable(int count, Instant lastLoginThreshold) { - JPAQuery query = query().selectFrom(FEED) - .distinct() - // join on subscriptions to only refresh feeds that have subscribers - .join(SUBSCRIPTION) - .on(SUBSCRIPTION.feed.eq(FEED)) - .where(FEED.disabledUntil.isNull().or(FEED.disabledUntil.lt(Instant.now()))); - - if (lastLoginThreshold != null) { - query.join(SUBSCRIPTION.user).where(SUBSCRIPTION.user.lastLogin.gt(lastLoginThreshold)); - } - - return query.orderBy(FEED.disabledUntil.asc()).limit(count).fetch(); - } - - public void setDisabledUntil(List feedIds, Instant date) { - updateQuery(FEED).set(FEED.disabledUntil, date).where(FEED.id.in(feedIds)).execute(); - } - - public Feed findByUrl(String normalizedUrl, String normalizedUrlHash) { - return query().selectFrom(FEED) - .where(FEED.normalizedUrlHash.eq(normalizedUrlHash)) - .fetch() - .stream() - .filter(f -> StringUtils.equals(normalizedUrl, f.getNormalizedUrl())) - .findFirst() - .orElse(null); - } - - public List findWithoutSubscriptions(int max) { - QFeedSubscription sub = QFeedSubscription.feedSubscription; - return query().selectFrom(FEED).where(JPAExpressions.selectOne().from(sub).where(sub.feed.eq(FEED)).notExists()).limit(max).fetch(); - } -} +package com.commafeed.backend.dao; + +import java.time.Instant; +import java.util.List; + +import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; + +import org.apache.commons.lang3.StringUtils; + +import com.commafeed.backend.model.Feed; +import com.commafeed.backend.model.QFeed; +import com.commafeed.backend.model.QFeedSubscription; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQuery; + +@Singleton +public class FeedDAO extends GenericDAO { + + private static final QFeed FEED = QFeed.feed; + private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription; + + public FeedDAO(EntityManager entityManager) { + super(entityManager, Feed.class); + } + + public List findByIds(List id) { + return query().selectFrom(FEED).where(FEED.id.in(id)).fetch(); + } + + public List findNextUpdatable(int count, Instant lastLoginThreshold) { + JPAQuery query = query().selectFrom(FEED) + .distinct() + // join on subscriptions to only refresh feeds that have subscribers + .join(SUBSCRIPTION) + .on(SUBSCRIPTION.feed.eq(FEED)) + .where(FEED.disabledUntil.isNull().or(FEED.disabledUntil.lt(Instant.now()))); + + if (lastLoginThreshold != null) { + query.join(SUBSCRIPTION.user).where(SUBSCRIPTION.user.lastLogin.gt(lastLoginThreshold)); + } + + return query.orderBy(FEED.disabledUntil.asc()).limit(count).fetch(); + } + + public void setDisabledUntil(List feedIds, Instant date) { + updateQuery(FEED).set(FEED.disabledUntil, date).where(FEED.id.in(feedIds)).execute(); + } + + public Feed findByUrl(String normalizedUrl, String normalizedUrlHash) { + return query().selectFrom(FEED) + .where(FEED.normalizedUrlHash.eq(normalizedUrlHash)) + .fetch() + .stream() + .filter(f -> StringUtils.equals(normalizedUrl, f.getNormalizedUrl())) + .findFirst() + .orElse(null); + } + + public List findWithoutSubscriptions(int max) { + QFeedSubscription sub = QFeedSubscription.feedSubscription; + return query().selectFrom(FEED).where(JPAExpressions.selectOne().from(sub).where(sub.feed.eq(FEED)).notExists()).limit(max).fetch(); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedEntryContentDAO.java b/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedEntryContentDAO.java index 4eb350e5..863e7226 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedEntryContentDAO.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedEntryContentDAO.java @@ -1,36 +1,36 @@ -package com.commafeed.backend.dao; - -import java.util.List; - -import jakarta.inject.Singleton; -import jakarta.persistence.EntityManager; - -import com.commafeed.backend.model.FeedEntryContent; -import com.commafeed.backend.model.QFeedEntry; -import com.commafeed.backend.model.QFeedEntryContent; - -@Singleton -public class FeedEntryContentDAO extends GenericDAO { - - private static final QFeedEntryContent CONTENT = QFeedEntryContent.feedEntryContent; - private static final QFeedEntry ENTRY = QFeedEntry.feedEntry; - - public FeedEntryContentDAO(EntityManager entityManager) { - super(entityManager, FeedEntryContent.class); - } - - public List findExisting(String contentHash, String titleHash) { - return query().select(CONTENT).from(CONTENT).where(CONTENT.contentHash.eq(contentHash), CONTENT.titleHash.eq(titleHash)).fetch(); - } - - public long deleteWithoutEntries(int max) { - List ids = query().select(CONTENT.id) - .from(CONTENT) - .leftJoin(ENTRY) - .on(ENTRY.content.id.eq(CONTENT.id)) - .where(ENTRY.id.isNull()) - .limit(max) - .fetch(); - return deleteQuery(CONTENT).where(CONTENT.id.in(ids)).execute(); - } -} +package com.commafeed.backend.dao; + +import java.util.List; + +import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; + +import com.commafeed.backend.model.FeedEntryContent; +import com.commafeed.backend.model.QFeedEntry; +import com.commafeed.backend.model.QFeedEntryContent; + +@Singleton +public class FeedEntryContentDAO extends GenericDAO { + + private static final QFeedEntryContent CONTENT = QFeedEntryContent.feedEntryContent; + private static final QFeedEntry ENTRY = QFeedEntry.feedEntry; + + public FeedEntryContentDAO(EntityManager entityManager) { + super(entityManager, FeedEntryContent.class); + } + + public List findExisting(String contentHash, String titleHash) { + return query().select(CONTENT).from(CONTENT).where(CONTENT.contentHash.eq(contentHash), CONTENT.titleHash.eq(titleHash)).fetch(); + } + + public long deleteWithoutEntries(int max) { + List ids = query().select(CONTENT.id) + .from(CONTENT) + .leftJoin(ENTRY) + .on(ENTRY.content.id.eq(CONTENT.id)) + .where(ENTRY.id.isNull()) + .limit(max) + .fetch(); + return deleteQuery(CONTENT).where(CONTENT.id.in(ids)).execute(); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedEntryDAO.java b/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedEntryDAO.java index 7c9de5d3..c7bfb678 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedEntryDAO.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedEntryDAO.java @@ -1,73 +1,73 @@ -package com.commafeed.backend.dao; - -import java.time.Instant; -import java.util.List; - -import jakarta.inject.Singleton; -import jakarta.persistence.EntityManager; - -import com.commafeed.backend.model.Feed; -import com.commafeed.backend.model.FeedEntry; -import com.commafeed.backend.model.QFeedEntry; -import com.querydsl.core.Tuple; -import com.querydsl.core.types.dsl.NumberExpression; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Singleton -public class FeedEntryDAO extends GenericDAO { - - private static final QFeedEntry ENTRY = QFeedEntry.feedEntry; - - public FeedEntryDAO(EntityManager entityManager) { - super(entityManager, FeedEntry.class); - } - - 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(); - } - - public List findFeedsExceedingCapacity(long maxCapacity, long max) { - NumberExpression count = ENTRY.id.count(); - List tuples = query().select(ENTRY.feed.id, count) - .from(ENTRY) - .groupBy(ENTRY.feed) - .having(count.gt(maxCapacity)) - .limit(max) - .fetch(); - return tuples.stream().map(t -> new FeedCapacity(t.get(ENTRY.feed.id), t.get(count))).toList(); - } - - public int delete(Long feedId, long max) { - List list = query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).limit(max).fetch(); - return delete(list); - } - - /** - * Delete entries older than a certain date - */ - public int deleteEntriesOlderThan(Instant olderThan, long max) { - List list = query().selectFrom(ENTRY) - .where(ENTRY.published.lt(olderThan)) - .orderBy(ENTRY.published.asc()) - .limit(max) - .fetch(); - return delete(list); - } - - /** - * Delete the oldest entries of a feed - */ - public int deleteOldEntries(Long feedId, long max) { - List list = query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).orderBy(ENTRY.published.asc()).limit(max).fetch(); - return delete(list); - } - - @AllArgsConstructor - @Getter - public static class FeedCapacity { - private Long id; - private Long capacity; - } -} +package com.commafeed.backend.dao; + +import java.time.Instant; +import java.util.List; + +import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; + +import com.commafeed.backend.model.Feed; +import com.commafeed.backend.model.FeedEntry; +import com.commafeed.backend.model.QFeedEntry; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.dsl.NumberExpression; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Singleton +public class FeedEntryDAO extends GenericDAO { + + private static final QFeedEntry ENTRY = QFeedEntry.feedEntry; + + public FeedEntryDAO(EntityManager entityManager) { + super(entityManager, FeedEntry.class); + } + + 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(); + } + + public List findFeedsExceedingCapacity(long maxCapacity, long max) { + NumberExpression count = ENTRY.id.count(); + List tuples = query().select(ENTRY.feed.id, count) + .from(ENTRY) + .groupBy(ENTRY.feed) + .having(count.gt(maxCapacity)) + .limit(max) + .fetch(); + return tuples.stream().map(t -> new FeedCapacity(t.get(ENTRY.feed.id), t.get(count))).toList(); + } + + public int delete(Long feedId, long max) { + List list = query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).limit(max).fetch(); + return delete(list); + } + + /** + * Delete entries older than a certain date + */ + public int deleteEntriesOlderThan(Instant olderThan, long max) { + List list = query().selectFrom(ENTRY) + .where(ENTRY.published.lt(olderThan)) + .orderBy(ENTRY.published.asc()) + .limit(max) + .fetch(); + return delete(list); + } + + /** + * Delete the oldest entries of a feed + */ + public int deleteOldEntries(Long feedId, long max) { + List list = query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).orderBy(ENTRY.published.asc()).limit(max).fetch(); + return delete(list); + } + + @AllArgsConstructor + @Getter + public static class FeedCapacity { + private Long id; + private Long capacity; + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedEntryStatusDAO.java b/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedEntryStatusDAO.java index 5ab6e6db..b72366eb 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedEntryStatusDAO.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedEntryStatusDAO.java @@ -1,236 +1,236 @@ -package com.commafeed.backend.dao; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import jakarta.inject.Singleton; -import jakarta.persistence.EntityManager; - -import org.apache.commons.collections4.CollectionUtils; - -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.feed.FeedEntryKeyword; -import com.commafeed.backend.feed.FeedEntryKeyword.Mode; -import com.commafeed.backend.model.FeedEntry; -import com.commafeed.backend.model.FeedEntryStatus; -import com.commafeed.backend.model.FeedEntryTag; -import com.commafeed.backend.model.FeedSubscription; -import com.commafeed.backend.model.QFeedEntry; -import com.commafeed.backend.model.QFeedEntryContent; -import com.commafeed.backend.model.QFeedEntryStatus; -import com.commafeed.backend.model.QFeedEntryTag; -import com.commafeed.backend.model.User; -import com.commafeed.backend.model.UserSettings.ReadingOrder; -import com.commafeed.frontend.model.UnreadCount; -import com.google.common.collect.Iterables; -import com.querydsl.core.BooleanBuilder; -import com.querydsl.core.Tuple; -import com.querydsl.jpa.impl.JPAQuery; - -@Singleton -public class FeedEntryStatusDAO extends GenericDAO { - - private static final QFeedEntryStatus STATUS = QFeedEntryStatus.feedEntryStatus; - private static final QFeedEntry ENTRY = QFeedEntry.feedEntry; - private static final QFeedEntryContent CONTENT = QFeedEntryContent.feedEntryContent; - private static final QFeedEntryTag TAG = QFeedEntryTag.feedEntryTag; - - private final FeedEntryTagDAO feedEntryTagDAO; - private final CommaFeedConfiguration config; - - public FeedEntryStatusDAO(EntityManager entityManager, FeedEntryTagDAO feedEntryTagDAO, CommaFeedConfiguration config) { - super(entityManager, FeedEntryStatus.class); - this.feedEntryTagDAO = feedEntryTagDAO; - this.config = config; - } - - public FeedEntryStatus getStatus(User user, FeedSubscription sub, FeedEntry entry) { - List statuses = query().selectFrom(STATUS).where(STATUS.entry.eq(entry), STATUS.subscription.eq(sub)).fetch(); - FeedEntryStatus status = Iterables.getFirst(statuses, null); - return handleStatus(user, status, sub, entry); - } - - /** - * creates an artificial "unread" status if status is null - */ - private FeedEntryStatus handleStatus(User user, FeedEntryStatus status, FeedSubscription sub, FeedEntry entry) { - if (status == null) { - Instant statusesInstantThreshold = config.database().cleanup().statusesInstantThreshold(); - boolean read = statusesInstantThreshold != null && entry.getPublished().isBefore(statusesInstantThreshold); - status = new FeedEntryStatus(user, sub, entry); - status.setRead(read); - status.setMarkable(!read); - } else { - status.setMarkable(true); - } - return status; - } - - private void fetchTags(User user, List statuses) { - Map> tagsByEntryIds = feedEntryTagDAO.findByEntries(user, - statuses.stream().map(FeedEntryStatus::getEntry).toList()); - for (FeedEntryStatus status : statuses) { - List tags = tagsByEntryIds.get(status.getEntry().getId()); - status.setTags(tags == null ? List.of() : tags); - } - } - - public List findStarred(User user, Instant newerThan, int offset, int limit, ReadingOrder order, - boolean includeContent) { - JPAQuery query = query().selectFrom(STATUS).where(STATUS.user.eq(user), STATUS.starred.isTrue()); - if (includeContent) { - query.join(STATUS.entry).fetchJoin(); - query.join(STATUS.entry.content).fetchJoin(); - } - - if (newerThan != null) { - query.where(STATUS.entryInserted.gt(newerThan)); - } - - if (order == ReadingOrder.asc) { - query.orderBy(STATUS.entryPublished.asc(), STATUS.id.asc()); - } else { - query.orderBy(STATUS.entryPublished.desc(), STATUS.id.desc()); - } - - if (offset > -1) { - query.offset(offset); - } - - if (limit > -1) { - query.limit(limit); - } - - setTimeout(query, config.database().queryTimeout()); - - List statuses = query.fetch(); - statuses.forEach(s -> s.setMarkable(true)); - if (includeContent) { - fetchTags(user, statuses); - } - - return statuses; - } - - public List findBySubscriptions(User user, List subs, boolean unreadOnly, - List keywords, Instant newerThan, int offset, int limit, ReadingOrder order, boolean includeContent, - String tag, Long minEntryId, Long maxEntryId) { - Map> subsByFeedId = subs.stream().collect(Collectors.groupingBy(s -> s.getFeed().getId())); - - JPAQuery query = query().select(ENTRY, STATUS).from(ENTRY); - query.leftJoin(ENTRY.statuses, STATUS).on(STATUS.subscription.in(subs)); - query.where(ENTRY.feed.id.in(subsByFeedId.keySet())); - - if (includeContent || CollectionUtils.isNotEmpty(keywords)) { - query.join(ENTRY.content, CONTENT).fetchJoin(); - } - if (CollectionUtils.isNotEmpty(keywords)) { - for (FeedEntryKeyword keyword : keywords) { - BooleanBuilder or = new BooleanBuilder(); - or.or(CONTENT.content.containsIgnoreCase(keyword.getKeyword())); - or.or(CONTENT.title.containsIgnoreCase(keyword.getKeyword())); - if (keyword.getMode() == Mode.EXCLUDE) { - or.not(); - } - query.where(or); - } - } - - if (unreadOnly && tag == null) { - query.where(buildUnreadPredicate()); - } - - if (tag != null) { - BooleanBuilder and = new BooleanBuilder(); - and.and(TAG.user.id.eq(user.getId())); - and.and(TAG.name.eq(tag)); - query.join(ENTRY.tags, TAG).on(and); - } - - if (newerThan != null) { - query.where(ENTRY.inserted.goe(newerThan)); - } - - if (minEntryId != null) { - query.where(ENTRY.id.gt(minEntryId)); - } - - if (maxEntryId != null) { - query.where(ENTRY.id.lt(maxEntryId)); - } - - if (order != null) { - if (order == ReadingOrder.asc) { - query.orderBy(ENTRY.published.asc(), ENTRY.id.asc()); - } else { - query.orderBy(ENTRY.published.desc(), ENTRY.id.desc()); - } - } - - if (offset > -1) { - query.offset(offset); - } - - if (limit > -1) { - query.limit(limit); - } - - setTimeout(query, config.database().queryTimeout()); - - List statuses = new ArrayList<>(); - List tuples = query.fetch(); - for (Tuple tuple : tuples) { - FeedEntry e = tuple.get(ENTRY); - FeedEntryStatus s = tuple.get(STATUS); - for (FeedSubscription sub : subsByFeedId.get(e.getFeed().getId())) { - statuses.add(handleStatus(user, s, sub, e)); - } - } - - if (includeContent) { - fetchTags(user, statuses); - } - - return statuses; - } - - public UnreadCount getUnreadCount(FeedSubscription sub) { - JPAQuery query = query().select(ENTRY.count(), ENTRY.published.max()) - .from(ENTRY) - .leftJoin(ENTRY.statuses, STATUS) - .on(STATUS.subscription.eq(sub)) - .where(ENTRY.feed.eq(sub.getFeed())) - .where(buildUnreadPredicate()); - - Tuple tuple = query.fetchOne(); - Long count = tuple.get(ENTRY.count()); - Instant published = tuple.get(ENTRY.published.max()); - return new UnreadCount(sub.getId(), count == null ? 0 : count, published); - } - - private BooleanBuilder buildUnreadPredicate() { - BooleanBuilder or = new BooleanBuilder(); - or.or(STATUS.read.isNull()); - or.or(STATUS.read.isFalse()); - - Instant statusesInstantThreshold = config.database().cleanup().statusesInstantThreshold(); - if (statusesInstantThreshold != null) { - return or.and(ENTRY.published.goe(statusesInstantThreshold)); - } else { - return or; - } - } - - public long deleteOldStatuses(Instant olderThan, int limit) { - List ids = query().select(STATUS.id) - .from(STATUS) - .where(STATUS.entryInserted.lt(olderThan), STATUS.starred.isFalse()) - .limit(limit) - .fetch(); - return deleteQuery(STATUS).where(STATUS.id.in(ids)).execute(); - } - -} +package com.commafeed.backend.dao; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; + +import org.apache.commons.collections4.CollectionUtils; + +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.backend.feed.FeedEntryKeyword; +import com.commafeed.backend.feed.FeedEntryKeyword.Mode; +import com.commafeed.backend.model.FeedEntry; +import com.commafeed.backend.model.FeedEntryStatus; +import com.commafeed.backend.model.FeedEntryTag; +import com.commafeed.backend.model.FeedSubscription; +import com.commafeed.backend.model.QFeedEntry; +import com.commafeed.backend.model.QFeedEntryContent; +import com.commafeed.backend.model.QFeedEntryStatus; +import com.commafeed.backend.model.QFeedEntryTag; +import com.commafeed.backend.model.User; +import com.commafeed.backend.model.UserSettings.ReadingOrder; +import com.commafeed.frontend.model.UnreadCount; +import com.google.common.collect.Iterables; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.Tuple; +import com.querydsl.jpa.impl.JPAQuery; + +@Singleton +public class FeedEntryStatusDAO extends GenericDAO { + + private static final QFeedEntryStatus STATUS = QFeedEntryStatus.feedEntryStatus; + private static final QFeedEntry ENTRY = QFeedEntry.feedEntry; + private static final QFeedEntryContent CONTENT = QFeedEntryContent.feedEntryContent; + private static final QFeedEntryTag TAG = QFeedEntryTag.feedEntryTag; + + private final FeedEntryTagDAO feedEntryTagDAO; + private final CommaFeedConfiguration config; + + public FeedEntryStatusDAO(EntityManager entityManager, FeedEntryTagDAO feedEntryTagDAO, CommaFeedConfiguration config) { + super(entityManager, FeedEntryStatus.class); + this.feedEntryTagDAO = feedEntryTagDAO; + this.config = config; + } + + public FeedEntryStatus getStatus(User user, FeedSubscription sub, FeedEntry entry) { + List statuses = query().selectFrom(STATUS).where(STATUS.entry.eq(entry), STATUS.subscription.eq(sub)).fetch(); + FeedEntryStatus status = Iterables.getFirst(statuses, null); + return handleStatus(user, status, sub, entry); + } + + /** + * creates an artificial "unread" status if status is null + */ + private FeedEntryStatus handleStatus(User user, FeedEntryStatus status, FeedSubscription sub, FeedEntry entry) { + if (status == null) { + Instant statusesInstantThreshold = config.database().cleanup().statusesInstantThreshold(); + boolean read = statusesInstantThreshold != null && entry.getPublished().isBefore(statusesInstantThreshold); + status = new FeedEntryStatus(user, sub, entry); + status.setRead(read); + status.setMarkable(!read); + } else { + status.setMarkable(true); + } + return status; + } + + private void fetchTags(User user, List statuses) { + Map> tagsByEntryIds = feedEntryTagDAO.findByEntries(user, + statuses.stream().map(FeedEntryStatus::getEntry).toList()); + for (FeedEntryStatus status : statuses) { + List tags = tagsByEntryIds.get(status.getEntry().getId()); + status.setTags(tags == null ? List.of() : tags); + } + } + + public List findStarred(User user, Instant newerThan, int offset, int limit, ReadingOrder order, + boolean includeContent) { + JPAQuery query = query().selectFrom(STATUS).where(STATUS.user.eq(user), STATUS.starred.isTrue()); + if (includeContent) { + query.join(STATUS.entry).fetchJoin(); + query.join(STATUS.entry.content).fetchJoin(); + } + + if (newerThan != null) { + query.where(STATUS.entryInserted.gt(newerThan)); + } + + if (order == ReadingOrder.asc) { + query.orderBy(STATUS.entryPublished.asc(), STATUS.id.asc()); + } else { + query.orderBy(STATUS.entryPublished.desc(), STATUS.id.desc()); + } + + if (offset > -1) { + query.offset(offset); + } + + if (limit > -1) { + query.limit(limit); + } + + setTimeout(query, config.database().queryTimeout()); + + List statuses = query.fetch(); + statuses.forEach(s -> s.setMarkable(true)); + if (includeContent) { + fetchTags(user, statuses); + } + + return statuses; + } + + public List findBySubscriptions(User user, List subs, boolean unreadOnly, + List keywords, Instant newerThan, int offset, int limit, ReadingOrder order, boolean includeContent, + String tag, Long minEntryId, Long maxEntryId) { + Map> subsByFeedId = subs.stream().collect(Collectors.groupingBy(s -> s.getFeed().getId())); + + JPAQuery query = query().select(ENTRY, STATUS).from(ENTRY); + query.leftJoin(ENTRY.statuses, STATUS).on(STATUS.subscription.in(subs)); + query.where(ENTRY.feed.id.in(subsByFeedId.keySet())); + + if (includeContent || CollectionUtils.isNotEmpty(keywords)) { + query.join(ENTRY.content, CONTENT).fetchJoin(); + } + if (CollectionUtils.isNotEmpty(keywords)) { + for (FeedEntryKeyword keyword : keywords) { + BooleanBuilder or = new BooleanBuilder(); + or.or(CONTENT.content.containsIgnoreCase(keyword.getKeyword())); + or.or(CONTENT.title.containsIgnoreCase(keyword.getKeyword())); + if (keyword.getMode() == Mode.EXCLUDE) { + or.not(); + } + query.where(or); + } + } + + if (unreadOnly && tag == null) { + query.where(buildUnreadPredicate()); + } + + if (tag != null) { + BooleanBuilder and = new BooleanBuilder(); + and.and(TAG.user.id.eq(user.getId())); + and.and(TAG.name.eq(tag)); + query.join(ENTRY.tags, TAG).on(and); + } + + if (newerThan != null) { + query.where(ENTRY.inserted.goe(newerThan)); + } + + if (minEntryId != null) { + query.where(ENTRY.id.gt(minEntryId)); + } + + if (maxEntryId != null) { + query.where(ENTRY.id.lt(maxEntryId)); + } + + if (order != null) { + if (order == ReadingOrder.asc) { + query.orderBy(ENTRY.published.asc(), ENTRY.id.asc()); + } else { + query.orderBy(ENTRY.published.desc(), ENTRY.id.desc()); + } + } + + if (offset > -1) { + query.offset(offset); + } + + if (limit > -1) { + query.limit(limit); + } + + setTimeout(query, config.database().queryTimeout()); + + List statuses = new ArrayList<>(); + List tuples = query.fetch(); + for (Tuple tuple : tuples) { + FeedEntry e = tuple.get(ENTRY); + FeedEntryStatus s = tuple.get(STATUS); + for (FeedSubscription sub : subsByFeedId.get(e.getFeed().getId())) { + statuses.add(handleStatus(user, s, sub, e)); + } + } + + if (includeContent) { + fetchTags(user, statuses); + } + + return statuses; + } + + public UnreadCount getUnreadCount(FeedSubscription sub) { + JPAQuery query = query().select(ENTRY.count(), ENTRY.published.max()) + .from(ENTRY) + .leftJoin(ENTRY.statuses, STATUS) + .on(STATUS.subscription.eq(sub)) + .where(ENTRY.feed.eq(sub.getFeed())) + .where(buildUnreadPredicate()); + + Tuple tuple = query.fetchOne(); + Long count = tuple.get(ENTRY.count()); + Instant published = tuple.get(ENTRY.published.max()); + return new UnreadCount(sub.getId(), count == null ? 0 : count, published); + } + + private BooleanBuilder buildUnreadPredicate() { + BooleanBuilder or = new BooleanBuilder(); + or.or(STATUS.read.isNull()); + or.or(STATUS.read.isFalse()); + + Instant statusesInstantThreshold = config.database().cleanup().statusesInstantThreshold(); + if (statusesInstantThreshold != null) { + return or.and(ENTRY.published.goe(statusesInstantThreshold)); + } else { + return or; + } + } + + public long deleteOldStatuses(Instant olderThan, int limit) { + List ids = query().select(STATUS.id) + .from(STATUS) + .where(STATUS.entryInserted.lt(olderThan), STATUS.starred.isFalse()) + .limit(limit) + .fetch(); + return deleteQuery(STATUS).where(STATUS.id.in(ids)).execute(); + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedEntryTagDAO.java b/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedEntryTagDAO.java index 15b57f1c..07d90af2 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedEntryTagDAO.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedEntryTagDAO.java @@ -1,39 +1,39 @@ -package com.commafeed.backend.dao; - -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import jakarta.inject.Singleton; -import jakarta.persistence.EntityManager; - -import com.commafeed.backend.model.FeedEntry; -import com.commafeed.backend.model.FeedEntryTag; -import com.commafeed.backend.model.QFeedEntryTag; -import com.commafeed.backend.model.User; - -@Singleton -public class FeedEntryTagDAO extends GenericDAO { - - private static final QFeedEntryTag TAG = QFeedEntryTag.feedEntryTag; - - public FeedEntryTagDAO(EntityManager entityManager) { - super(entityManager, FeedEntryTag.class); - } - - public List findByUser(User user) { - return query().selectDistinct(TAG.name).from(TAG).where(TAG.user.eq(user)).fetch(); - } - - public List findByEntry(User user, FeedEntry entry) { - return query().selectFrom(TAG).where(TAG.user.eq(user), TAG.entry.eq(entry)).fetch(); - } - - public Map> findByEntries(User user, List entries) { - return query().selectFrom(TAG) - .where(TAG.user.eq(user), TAG.entry.in(entries)) - .fetch() - .stream() - .collect(Collectors.groupingBy(t -> t.getEntry().getId())); - } -} +package com.commafeed.backend.dao; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; + +import com.commafeed.backend.model.FeedEntry; +import com.commafeed.backend.model.FeedEntryTag; +import com.commafeed.backend.model.QFeedEntryTag; +import com.commafeed.backend.model.User; + +@Singleton +public class FeedEntryTagDAO extends GenericDAO { + + private static final QFeedEntryTag TAG = QFeedEntryTag.feedEntryTag; + + public FeedEntryTagDAO(EntityManager entityManager) { + super(entityManager, FeedEntryTag.class); + } + + public List findByUser(User user) { + return query().selectDistinct(TAG.name).from(TAG).where(TAG.user.eq(user)).fetch(); + } + + public List findByEntry(User user, FeedEntry entry) { + return query().selectFrom(TAG).where(TAG.user.eq(user), TAG.entry.eq(entry)).fetch(); + } + + public Map> findByEntries(User user, List entries) { + return query().selectFrom(TAG) + .where(TAG.user.eq(user), TAG.entry.in(entries)) + .fetch() + .stream() + .collect(Collectors.groupingBy(t -> t.getEntry().getId())); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedSubscriptionDAO.java b/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedSubscriptionDAO.java index 440b1d9b..af6bd00f 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedSubscriptionDAO.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/dao/FeedSubscriptionDAO.java @@ -1,130 +1,130 @@ -package com.commafeed.backend.dao; - -import java.util.List; -import java.util.Set; -import java.util.function.Consumer; -import java.util.stream.Collectors; - -import jakarta.inject.Singleton; -import jakarta.persistence.EntityManager; - -import org.hibernate.engine.spi.SharedSessionContractImplementor; -import org.hibernate.event.service.spi.EventListenerRegistry; -import org.hibernate.event.spi.EventType; -import org.hibernate.event.spi.PostCommitInsertEventListener; -import org.hibernate.event.spi.PostInsertEvent; -import org.hibernate.persister.entity.EntityPersister; - -import com.commafeed.backend.model.AbstractModel; -import com.commafeed.backend.model.Feed; -import com.commafeed.backend.model.FeedCategory; -import com.commafeed.backend.model.FeedSubscription; -import com.commafeed.backend.model.Models; -import com.commafeed.backend.model.QFeedSubscription; -import com.commafeed.backend.model.User; -import com.google.common.collect.Iterables; -import com.querydsl.jpa.JPQLQuery; - -@Singleton -public class FeedSubscriptionDAO extends GenericDAO { - - private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription; - - private final EntityManager entityManager; - - public FeedSubscriptionDAO(EntityManager entityManager) { - super(entityManager, FeedSubscription.class); - this.entityManager = entityManager; - } - - public void onPostCommitInsert(Consumer consumer) { - entityManager.unwrap(SharedSessionContractImplementor.class) - .getFactory() - .getServiceRegistry() - .getService(EventListenerRegistry.class) - .getEventListenerGroup(EventType.POST_COMMIT_INSERT) - .appendListener(new PostCommitInsertEventListener() { - @Override - public void onPostInsert(PostInsertEvent event) { - if (event.getEntity() instanceof FeedSubscription s) { - consumer.accept(s); - } - } - - @Override - public boolean requiresPostCommitHandling(EntityPersister persister) { - return true; - } - - @Override - public void onPostInsertCommitFailed(PostInsertEvent event) { - // do nothing - } - }); - } - - public FeedSubscription findById(User user, Long id) { - List subs = query().selectFrom(SUBSCRIPTION) - .where(SUBSCRIPTION.user.eq(user), SUBSCRIPTION.id.eq(id)) - .leftJoin(SUBSCRIPTION.feed) - .fetchJoin() - .leftJoin(SUBSCRIPTION.category) - .fetchJoin() - .fetch(); - return initRelations(Iterables.getFirst(subs, null)); - } - - public List findByFeed(Feed feed) { - return query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.feed.eq(feed)).fetch(); - } - - public FeedSubscription findByFeed(User user, Feed feed) { - List subs = query().selectFrom(SUBSCRIPTION) - .where(SUBSCRIPTION.user.eq(user), SUBSCRIPTION.feed.eq(feed)) - .fetch(); - return initRelations(Iterables.getFirst(subs, null)); - } - - public List findAll(User user) { - List subs = query().selectFrom(SUBSCRIPTION) - .where(SUBSCRIPTION.user.eq(user)) - .leftJoin(SUBSCRIPTION.feed) - .fetchJoin() - .leftJoin(SUBSCRIPTION.category) - .fetchJoin() - .fetch(); - return initRelations(subs); - } - - public Long count(User user) { - return query().select(SUBSCRIPTION.count()).from(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user)).fetchOne(); - } - - public List findByCategory(User user, FeedCategory category) { - JPQLQuery query = query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user)); - if (category == null) { - query.where(SUBSCRIPTION.category.isNull()); - } else { - query.where(SUBSCRIPTION.category.eq(category)); - } - return initRelations(query.fetch()); - } - - public List findByCategories(User user, List categories) { - Set categoryIds = categories.stream().map(AbstractModel::getId).collect(Collectors.toSet()); - return findAll(user).stream().filter(s -> s.getCategory() != null && categoryIds.contains(s.getCategory().getId())).toList(); - } - - private List initRelations(List list) { - list.forEach(this::initRelations); - return list; - } - - private FeedSubscription initRelations(FeedSubscription sub) { - if (sub != null) { - Models.initialize(sub.getFeed()); - Models.initialize(sub.getCategory()); - } - return sub; - } -} +package com.commafeed.backend.dao; + +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; + +import org.hibernate.engine.spi.SharedSessionContractImplementor; +import org.hibernate.event.service.spi.EventListenerRegistry; +import org.hibernate.event.spi.EventType; +import org.hibernate.event.spi.PostCommitInsertEventListener; +import org.hibernate.event.spi.PostInsertEvent; +import org.hibernate.persister.entity.EntityPersister; + +import com.commafeed.backend.model.AbstractModel; +import com.commafeed.backend.model.Feed; +import com.commafeed.backend.model.FeedCategory; +import com.commafeed.backend.model.FeedSubscription; +import com.commafeed.backend.model.Models; +import com.commafeed.backend.model.QFeedSubscription; +import com.commafeed.backend.model.User; +import com.google.common.collect.Iterables; +import com.querydsl.jpa.JPQLQuery; + +@Singleton +public class FeedSubscriptionDAO extends GenericDAO { + + private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription; + + private final EntityManager entityManager; + + public FeedSubscriptionDAO(EntityManager entityManager) { + super(entityManager, FeedSubscription.class); + this.entityManager = entityManager; + } + + public void onPostCommitInsert(Consumer consumer) { + entityManager.unwrap(SharedSessionContractImplementor.class) + .getFactory() + .getServiceRegistry() + .getService(EventListenerRegistry.class) + .getEventListenerGroup(EventType.POST_COMMIT_INSERT) + .appendListener(new PostCommitInsertEventListener() { + @Override + public void onPostInsert(PostInsertEvent event) { + if (event.getEntity() instanceof FeedSubscription s) { + consumer.accept(s); + } + } + + @Override + public boolean requiresPostCommitHandling(EntityPersister persister) { + return true; + } + + @Override + public void onPostInsertCommitFailed(PostInsertEvent event) { + // do nothing + } + }); + } + + public FeedSubscription findById(User user, Long id) { + List subs = query().selectFrom(SUBSCRIPTION) + .where(SUBSCRIPTION.user.eq(user), SUBSCRIPTION.id.eq(id)) + .leftJoin(SUBSCRIPTION.feed) + .fetchJoin() + .leftJoin(SUBSCRIPTION.category) + .fetchJoin() + .fetch(); + return initRelations(Iterables.getFirst(subs, null)); + } + + public List findByFeed(Feed feed) { + return query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.feed.eq(feed)).fetch(); + } + + public FeedSubscription findByFeed(User user, Feed feed) { + List subs = query().selectFrom(SUBSCRIPTION) + .where(SUBSCRIPTION.user.eq(user), SUBSCRIPTION.feed.eq(feed)) + .fetch(); + return initRelations(Iterables.getFirst(subs, null)); + } + + public List findAll(User user) { + List subs = query().selectFrom(SUBSCRIPTION) + .where(SUBSCRIPTION.user.eq(user)) + .leftJoin(SUBSCRIPTION.feed) + .fetchJoin() + .leftJoin(SUBSCRIPTION.category) + .fetchJoin() + .fetch(); + return initRelations(subs); + } + + public Long count(User user) { + return query().select(SUBSCRIPTION.count()).from(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user)).fetchOne(); + } + + public List findByCategory(User user, FeedCategory category) { + JPQLQuery query = query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user)); + if (category == null) { + query.where(SUBSCRIPTION.category.isNull()); + } else { + query.where(SUBSCRIPTION.category.eq(category)); + } + return initRelations(query.fetch()); + } + + public List findByCategories(User user, List categories) { + Set categoryIds = categories.stream().map(AbstractModel::getId).collect(Collectors.toSet()); + return findAll(user).stream().filter(s -> s.getCategory() != null && categoryIds.contains(s.getCategory().getId())).toList(); + } + + private List initRelations(List list) { + list.forEach(this::initRelations); + return list; + } + + private FeedSubscription initRelations(FeedSubscription sub) { + if (sub != null) { + Models.initialize(sub.getFeed()); + Models.initialize(sub.getCategory()); + } + return sub; + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/dao/GenericDAO.java b/commafeed-server/src/main/java/com/commafeed/backend/dao/GenericDAO.java index 5d3713c1..a337576f 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/dao/GenericDAO.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/dao/GenericDAO.java @@ -1,76 +1,76 @@ -package com.commafeed.backend.dao; - -import java.time.Duration; -import java.util.Collection; - -import jakarta.persistence.EntityManager; - -import org.hibernate.Session; -import org.hibernate.jpa.SpecHints; - -import com.commafeed.backend.model.AbstractModel; -import com.querydsl.core.types.EntityPath; -import com.querydsl.jpa.impl.JPADeleteClause; -import com.querydsl.jpa.impl.JPAQuery; -import com.querydsl.jpa.impl.JPAQueryFactory; -import com.querydsl.jpa.impl.JPAUpdateClause; - -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -public abstract class GenericDAO { - - private final EntityManager entityManager; - private final Class entityClass; - - protected JPAQueryFactory query() { - return new JPAQueryFactory(entityManager); - } - - protected JPAUpdateClause updateQuery(EntityPath entityPath) { - return new JPAUpdateClause(entityManager, entityPath); - } - - protected JPADeleteClause deleteQuery(EntityPath entityPath) { - return new JPADeleteClause(entityManager, entityPath); - } - - @SuppressWarnings("deprecation") - public void saveOrUpdate(T model) { - entityManager.unwrap(Session.class).saveOrUpdate(model); - } - - public void saveOrUpdate(Collection models) { - models.forEach(this::saveOrUpdate); - } - - public void persist(T model) { - entityManager.persist(model); - } - - public T merge(T model) { - return entityManager.merge(model); - } - - public T findById(Long id) { - return entityManager.find(entityClass, id); - } - - public void delete(T object) { - if (object != null) { - entityManager.remove(object); - } - } - - public int delete(Collection objects) { - objects.forEach(this::delete); - return objects.size(); - } - - protected void setTimeout(JPAQuery query, Duration timeout) { - if (!timeout.isZero()) { - query.setHint(SpecHints.HINT_SPEC_QUERY_TIMEOUT, Math.toIntExact(timeout.toMillis())); - } - } - -} +package com.commafeed.backend.dao; + +import java.time.Duration; +import java.util.Collection; + +import jakarta.persistence.EntityManager; + +import org.hibernate.Session; +import org.hibernate.jpa.SpecHints; + +import com.commafeed.backend.model.AbstractModel; +import com.querydsl.core.types.EntityPath; +import com.querydsl.jpa.impl.JPADeleteClause; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.querydsl.jpa.impl.JPAUpdateClause; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public abstract class GenericDAO { + + private final EntityManager entityManager; + private final Class entityClass; + + protected JPAQueryFactory query() { + return new JPAQueryFactory(entityManager); + } + + protected JPAUpdateClause updateQuery(EntityPath entityPath) { + return new JPAUpdateClause(entityManager, entityPath); + } + + protected JPADeleteClause deleteQuery(EntityPath entityPath) { + return new JPADeleteClause(entityManager, entityPath); + } + + @SuppressWarnings("deprecation") + public void saveOrUpdate(T model) { + entityManager.unwrap(Session.class).saveOrUpdate(model); + } + + public void saveOrUpdate(Collection models) { + models.forEach(this::saveOrUpdate); + } + + public void persist(T model) { + entityManager.persist(model); + } + + public T merge(T model) { + return entityManager.merge(model); + } + + public T findById(Long id) { + return entityManager.find(entityClass, id); + } + + public void delete(T object) { + if (object != null) { + entityManager.remove(object); + } + } + + public int delete(Collection objects) { + objects.forEach(this::delete); + return objects.size(); + } + + protected void setTimeout(JPAQuery query, Duration timeout) { + if (!timeout.isZero()) { + query.setHint(SpecHints.HINT_SPEC_QUERY_TIMEOUT, Math.toIntExact(timeout.toMillis())); + } + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/dao/UnitOfWork.java b/commafeed-server/src/main/java/com/commafeed/backend/dao/UnitOfWork.java index a52e580f..e168392e 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/dao/UnitOfWork.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/dao/UnitOfWork.java @@ -1,19 +1,19 @@ -package com.commafeed.backend.dao; - -import java.util.concurrent.Callable; - -import jakarta.inject.Singleton; - -import io.quarkus.narayana.jta.QuarkusTransaction; - -@Singleton -public class UnitOfWork { - - public void run(Runnable runnable) { - QuarkusTransaction.joiningExisting().run(runnable); - } - - public T call(Callable callable) { - return QuarkusTransaction.joiningExisting().call(callable); - } -} +package com.commafeed.backend.dao; + +import java.util.concurrent.Callable; + +import jakarta.inject.Singleton; + +import io.quarkus.narayana.jta.QuarkusTransaction; + +@Singleton +public class UnitOfWork { + + public void run(Runnable runnable) { + QuarkusTransaction.joiningExisting().run(runnable); + } + + public T call(Callable callable) { + return QuarkusTransaction.joiningExisting().call(callable); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/dao/UserDAO.java b/commafeed-server/src/main/java/com/commafeed/backend/dao/UserDAO.java index cda95d92..8353fadf 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/dao/UserDAO.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/dao/UserDAO.java @@ -1,33 +1,33 @@ -package com.commafeed.backend.dao; - -import jakarta.inject.Singleton; -import jakarta.persistence.EntityManager; - -import com.commafeed.backend.model.QUser; -import com.commafeed.backend.model.User; - -@Singleton -public class UserDAO extends GenericDAO { - - private static final QUser USER = QUser.user; - - public UserDAO(EntityManager entityManager) { - super(entityManager, User.class); - } - - public User findByName(String name) { - return query().selectFrom(USER).where(USER.name.equalsIgnoreCase(name)).fetchOne(); - } - - public User findByApiKey(String key) { - return query().selectFrom(USER).where(USER.apiKey.equalsIgnoreCase(key)).fetchOne(); - } - - public User findByEmail(String email) { - return query().selectFrom(USER).where(USER.email.equalsIgnoreCase(email)).fetchOne(); - } - - public long count() { - return query().select(USER.count()).from(USER).fetchOne(); - } -} +package com.commafeed.backend.dao; + +import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; + +import com.commafeed.backend.model.QUser; +import com.commafeed.backend.model.User; + +@Singleton +public class UserDAO extends GenericDAO { + + private static final QUser USER = QUser.user; + + public UserDAO(EntityManager entityManager) { + super(entityManager, User.class); + } + + public User findByName(String name) { + return query().selectFrom(USER).where(USER.name.equalsIgnoreCase(name)).fetchOne(); + } + + public User findByApiKey(String key) { + return query().selectFrom(USER).where(USER.apiKey.equalsIgnoreCase(key)).fetchOne(); + } + + public User findByEmail(String email) { + return query().selectFrom(USER).where(USER.email.equalsIgnoreCase(email)).fetchOne(); + } + + public long count() { + return query().select(USER.count()).from(USER).fetchOne(); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/dao/UserRoleDAO.java b/commafeed-server/src/main/java/com/commafeed/backend/dao/UserRoleDAO.java index e08be923..2831f67e 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/dao/UserRoleDAO.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/dao/UserRoleDAO.java @@ -1,35 +1,35 @@ -package com.commafeed.backend.dao; - -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -import jakarta.inject.Singleton; -import jakarta.persistence.EntityManager; - -import com.commafeed.backend.model.QUserRole; -import com.commafeed.backend.model.User; -import com.commafeed.backend.model.UserRole; -import com.commafeed.backend.model.UserRole.Role; - -@Singleton -public class UserRoleDAO extends GenericDAO { - - private static final QUserRole ROLE = QUserRole.userRole; - - public UserRoleDAO(EntityManager entityManager) { - super(entityManager, UserRole.class); - } - - public List findAll() { - return query().selectFrom(ROLE).leftJoin(ROLE.user).fetchJoin().distinct().fetch(); - } - - public List findAll(User user) { - return query().selectFrom(ROLE).where(ROLE.user.eq(user)).distinct().fetch(); - } - - public Set findRoles(User user) { - return findAll(user).stream().map(UserRole::getRole).collect(Collectors.toSet()); - } -} +package com.commafeed.backend.dao; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; + +import com.commafeed.backend.model.QUserRole; +import com.commafeed.backend.model.User; +import com.commafeed.backend.model.UserRole; +import com.commafeed.backend.model.UserRole.Role; + +@Singleton +public class UserRoleDAO extends GenericDAO { + + private static final QUserRole ROLE = QUserRole.userRole; + + public UserRoleDAO(EntityManager entityManager) { + super(entityManager, UserRole.class); + } + + public List findAll() { + return query().selectFrom(ROLE).leftJoin(ROLE.user).fetchJoin().distinct().fetch(); + } + + public List findAll(User user) { + return query().selectFrom(ROLE).where(ROLE.user.eq(user)).distinct().fetch(); + } + + public Set findRoles(User user) { + return findAll(user).stream().map(UserRole::getRole).collect(Collectors.toSet()); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/dao/UserSettingsDAO.java b/commafeed-server/src/main/java/com/commafeed/backend/dao/UserSettingsDAO.java index a1a3e758..75e5a269 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/dao/UserSettingsDAO.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/dao/UserSettingsDAO.java @@ -1,22 +1,22 @@ -package com.commafeed.backend.dao; - -import jakarta.inject.Singleton; -import jakarta.persistence.EntityManager; - -import com.commafeed.backend.model.QUserSettings; -import com.commafeed.backend.model.User; -import com.commafeed.backend.model.UserSettings; - -@Singleton -public class UserSettingsDAO extends GenericDAO { - - private static final QUserSettings SETTINGS = QUserSettings.userSettings; - - public UserSettingsDAO(EntityManager entityManager) { - super(entityManager, UserSettings.class); - } - - public UserSettings findByUser(User user) { - return query().selectFrom(SETTINGS).where(SETTINGS.user.eq(user)).fetchFirst(); - } -} +package com.commafeed.backend.dao; + +import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; + +import com.commafeed.backend.model.QUserSettings; +import com.commafeed.backend.model.User; +import com.commafeed.backend.model.UserSettings; + +@Singleton +public class UserSettingsDAO extends GenericDAO { + + private static final QUserSettings SETTINGS = QUserSettings.userSettings; + + public UserSettingsDAO(EntityManager entityManager) { + super(entityManager, UserSettings.class); + } + + public UserSettings findByUser(User user) { + return query().selectFrom(SETTINGS).where(SETTINGS.user.eq(user)).fetchFirst(); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/favicon/AbstractFaviconFetcher.java b/commafeed-server/src/main/java/com/commafeed/backend/favicon/AbstractFaviconFetcher.java index c33fc5f0..3ea453d6 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/favicon/AbstractFaviconFetcher.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/favicon/AbstractFaviconFetcher.java @@ -1,49 +1,49 @@ -package com.commafeed.backend.favicon; - -import java.util.Arrays; -import java.util.List; - -import org.apache.commons.lang3.StringUtils; - -import com.commafeed.backend.model.Feed; - -import lombok.extern.slf4j.Slf4j; - -@Slf4j -public abstract class AbstractFaviconFetcher { - - private static final List ICON_MIMETYPE_BLACKLIST = Arrays.asList("application/xml", "text/html"); - private static final long MIN_ICON_LENGTH = 100; - private static final long MAX_ICON_LENGTH = 100000; - - public abstract Favicon fetch(Feed feed); - - protected boolean isValidIconResponse(byte[] content, String contentType) { - if (content == null) { - return false; - } - - long length = content.length; - - if (StringUtils.isNotBlank(contentType)) { - contentType = contentType.split(";")[0]; - } - - if (ICON_MIMETYPE_BLACKLIST.contains(contentType)) { - log.debug("Content-Type {} is blacklisted", contentType); - return false; - } - - if (length < MIN_ICON_LENGTH) { - log.debug("Length {} below MIN_ICON_LENGTH {}", length, MIN_ICON_LENGTH); - return false; - } - - if (length > MAX_ICON_LENGTH) { - log.debug("Length {} greater than MAX_ICON_LENGTH {}", length, MAX_ICON_LENGTH); - return false; - } - - return true; - } -} +package com.commafeed.backend.favicon; + +import java.util.Arrays; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; + +import com.commafeed.backend.model.Feed; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public abstract class AbstractFaviconFetcher { + + private static final List ICON_MIMETYPE_BLACKLIST = Arrays.asList("application/xml", "text/html"); + private static final long MIN_ICON_LENGTH = 100; + private static final long MAX_ICON_LENGTH = 100000; + + public abstract Favicon fetch(Feed feed); + + protected boolean isValidIconResponse(byte[] content, String contentType) { + if (content == null) { + return false; + } + + long length = content.length; + + if (StringUtils.isNotBlank(contentType)) { + contentType = contentType.split(";")[0]; + } + + if (ICON_MIMETYPE_BLACKLIST.contains(contentType)) { + log.debug("Content-Type {} is blacklisted", contentType); + return false; + } + + if (length < MIN_ICON_LENGTH) { + log.debug("Length {} below MIN_ICON_LENGTH {}", length, MIN_ICON_LENGTH); + return false; + } + + if (length > MAX_ICON_LENGTH) { + log.debug("Length {} greater than MAX_ICON_LENGTH {}", length, MAX_ICON_LENGTH); + return false; + } + + return true; + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/favicon/DefaultFaviconFetcher.java b/commafeed-server/src/main/java/com/commafeed/backend/favicon/DefaultFaviconFetcher.java index 180bdc1c..f04c4628 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/favicon/DefaultFaviconFetcher.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/favicon/DefaultFaviconFetcher.java @@ -1,133 +1,133 @@ -package com.commafeed.backend.favicon; - -import jakarta.annotation.Priority; -import jakarta.inject.Singleton; - -import org.apache.commons.lang3.StringUtils; -import org.jsoup.Jsoup; -import org.jsoup.nodes.Document; -import org.jsoup.select.Elements; - -import com.commafeed.backend.HttpGetter; -import com.commafeed.backend.HttpGetter.HttpResult; -import com.commafeed.backend.feed.FeedUtils; -import com.commafeed.backend.model.Feed; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -/** - * Inspired/Ported from https://github.com/potatolondon/getfavicon - * - */ -@Slf4j -@RequiredArgsConstructor -@Singleton -@Priority(Integer.MIN_VALUE) -public class DefaultFaviconFetcher extends AbstractFaviconFetcher { - - private final HttpGetter getter; - - @Override - public Favicon fetch(Feed feed) { - Favicon icon = fetch(feed.getLink()); - if (icon == null) { - icon = fetch(feed.getUrl()); - } - return icon; - } - - private Favicon fetch(String url) { - if (url == null) { - log.debug("url is null"); - return null; - } - - int doubleSlash = url.indexOf("//"); - if (doubleSlash == -1) { - doubleSlash = 0; - } else { - doubleSlash += 2; - } - int firstSlash = url.indexOf('/', doubleSlash); - if (firstSlash != -1) { - url = url.substring(0, firstSlash); - } - - Favicon icon = getIconAtRoot(url); - - if (icon == null) { - icon = getIconInPage(url); - } - - return icon; - } - - private Favicon getIconAtRoot(String url) { - byte[] bytes = null; - String contentType = null; - - try { - url = FeedUtils.removeTrailingSlash(url) + "/favicon.ico"; - log.debug("getting root icon at {}", url); - HttpResult result = getter.get(url); - bytes = result.getContent(); - contentType = result.getContentType(); - } catch (Exception e) { - log.debug("Failed to retrieve iconAtRoot for url {}: ", url); - log.trace("Failed to retrieve iconAtRoot for url {}: ", url, e); - } - - if (!isValidIconResponse(bytes, contentType)) { - return null; - } - return new Favicon(bytes, contentType); - } - - private Favicon getIconInPage(String url) { - - Document doc; - try { - HttpResult result = getter.get(url); - doc = Jsoup.parse(new String(result.getContent()), url); - } catch (Exception e) { - log.debug("Failed to retrieve page to find icon"); - log.trace("Failed to retrieve page to find icon", e); - return null; - } - - Elements icons = doc.select("link[rel~=(?i)^(shortcut|icon|shortcut icon)$]"); - - if (icons.isEmpty()) { - log.debug("No icon found in page {}", url); - return null; - } - - String href = icons.get(0).attr("abs:href"); - if (StringUtils.isBlank(href)) { - log.debug("No icon found in page"); - return null; - } - - log.debug("Found unconfirmed iconInPage at {}", href); - - byte[] bytes; - String contentType; - try { - HttpResult result = getter.get(href); - bytes = result.getContent(); - contentType = result.getContentType(); - } catch (Exception e) { - log.debug("Failed to retrieve icon found in page {}", href); - log.trace("Failed to retrieve icon found in page {}", href, e); - return null; - } - - if (!isValidIconResponse(bytes, contentType)) { - log.debug("Invalid icon found for {}", href); - return null; - } - - return new Favicon(bytes, contentType); - } -} +package com.commafeed.backend.favicon; + +import jakarta.annotation.Priority; +import jakarta.inject.Singleton; + +import org.apache.commons.lang3.StringUtils; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.select.Elements; + +import com.commafeed.backend.HttpGetter; +import com.commafeed.backend.HttpGetter.HttpResult; +import com.commafeed.backend.feed.FeedUtils; +import com.commafeed.backend.model.Feed; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Inspired/Ported from https://github.com/potatolondon/getfavicon + * + */ +@Slf4j +@RequiredArgsConstructor +@Singleton +@Priority(Integer.MIN_VALUE) +public class DefaultFaviconFetcher extends AbstractFaviconFetcher { + + private final HttpGetter getter; + + @Override + public Favicon fetch(Feed feed) { + Favicon icon = fetch(feed.getLink()); + if (icon == null) { + icon = fetch(feed.getUrl()); + } + return icon; + } + + private Favicon fetch(String url) { + if (url == null) { + log.debug("url is null"); + return null; + } + + int doubleSlash = url.indexOf("//"); + if (doubleSlash == -1) { + doubleSlash = 0; + } else { + doubleSlash += 2; + } + int firstSlash = url.indexOf('/', doubleSlash); + if (firstSlash != -1) { + url = url.substring(0, firstSlash); + } + + Favicon icon = getIconAtRoot(url); + + if (icon == null) { + icon = getIconInPage(url); + } + + return icon; + } + + private Favicon getIconAtRoot(String url) { + byte[] bytes = null; + String contentType = null; + + try { + url = FeedUtils.removeTrailingSlash(url) + "/favicon.ico"; + log.debug("getting root icon at {}", url); + HttpResult result = getter.get(url); + bytes = result.getContent(); + contentType = result.getContentType(); + } catch (Exception e) { + log.debug("Failed to retrieve iconAtRoot for url {}: ", url); + log.trace("Failed to retrieve iconAtRoot for url {}: ", url, e); + } + + if (!isValidIconResponse(bytes, contentType)) { + return null; + } + return new Favicon(bytes, contentType); + } + + private Favicon getIconInPage(String url) { + + Document doc; + try { + HttpResult result = getter.get(url); + doc = Jsoup.parse(new String(result.getContent()), url); + } catch (Exception e) { + log.debug("Failed to retrieve page to find icon"); + log.trace("Failed to retrieve page to find icon", e); + return null; + } + + Elements icons = doc.select("link[rel~=(?i)^(shortcut|icon|shortcut icon)$]"); + + if (icons.isEmpty()) { + log.debug("No icon found in page {}", url); + return null; + } + + String href = icons.get(0).attr("abs:href"); + if (StringUtils.isBlank(href)) { + log.debug("No icon found in page"); + return null; + } + + log.debug("Found unconfirmed iconInPage at {}", href); + + byte[] bytes; + String contentType; + try { + HttpResult result = getter.get(href); + bytes = result.getContent(); + contentType = result.getContentType(); + } catch (Exception e) { + log.debug("Failed to retrieve icon found in page {}", href); + log.trace("Failed to retrieve icon found in page {}", href, e); + return null; + } + + if (!isValidIconResponse(bytes, contentType)) { + log.debug("Invalid icon found for {}", href); + return null; + } + + return new Favicon(bytes, contentType); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/favicon/FacebookFaviconFetcher.java b/commafeed-server/src/main/java/com/commafeed/backend/favicon/FacebookFaviconFetcher.java index b0bc509d..f60f1ab8 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/favicon/FacebookFaviconFetcher.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/favicon/FacebookFaviconFetcher.java @@ -1,73 +1,73 @@ -package com.commafeed.backend.favicon; - -import java.net.URI; -import java.net.URISyntaxException; -import java.util.List; - -import jakarta.inject.Singleton; - -import org.apache.hc.core5.http.NameValuePair; -import org.apache.hc.core5.net.URIBuilder; - -import com.commafeed.backend.HttpGetter; -import com.commafeed.backend.HttpGetter.HttpResult; -import com.commafeed.backend.model.Feed; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@RequiredArgsConstructor -@Singleton -public class FacebookFaviconFetcher extends AbstractFaviconFetcher { - - private final HttpGetter getter; - - @Override - public Favicon fetch(Feed feed) { - String url = feed.getUrl(); - - if (!url.toLowerCase().contains("www.facebook.com")) { - return null; - } - - String userName = extractUserName(url); - if (userName == null) { - return null; - } - - String iconUrl = String.format("https://graph.facebook.com/%s/picture?type=square&height=16", userName); - - byte[] bytes = null; - String contentType = null; - - try { - log.debug("Getting Facebook user's icon, {}", url); - - HttpResult iconResult = getter.get(iconUrl); - bytes = iconResult.getContent(); - contentType = iconResult.getContentType(); - } catch (Exception e) { - log.debug("Failed to retrieve Facebook icon", e); - } - - if (!isValidIconResponse(bytes, contentType)) { - return null; - } - return new Favicon(bytes, contentType); - } - - private String extractUserName(String url) { - URI uri; - try { - uri = new URI(url); - } catch (URISyntaxException e) { - log.debug("could not parse url", e); - return null; - } - - List params = new URIBuilder(uri).getQueryParams(); - return params.stream().filter(p -> "id".equals(p.getName())).map(NameValuePair::getValue).findFirst().orElse(null); - } - -} +package com.commafeed.backend.favicon; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; + +import jakarta.inject.Singleton; + +import org.apache.hc.core5.http.NameValuePair; +import org.apache.hc.core5.net.URIBuilder; + +import com.commafeed.backend.HttpGetter; +import com.commafeed.backend.HttpGetter.HttpResult; +import com.commafeed.backend.model.Feed; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Singleton +public class FacebookFaviconFetcher extends AbstractFaviconFetcher { + + private final HttpGetter getter; + + @Override + public Favicon fetch(Feed feed) { + String url = feed.getUrl(); + + if (!url.toLowerCase().contains("www.facebook.com")) { + return null; + } + + String userName = extractUserName(url); + if (userName == null) { + return null; + } + + String iconUrl = String.format("https://graph.facebook.com/%s/picture?type=square&height=16", userName); + + byte[] bytes = null; + String contentType = null; + + try { + log.debug("Getting Facebook user's icon, {}", url); + + HttpResult iconResult = getter.get(iconUrl); + bytes = iconResult.getContent(); + contentType = iconResult.getContentType(); + } catch (Exception e) { + log.debug("Failed to retrieve Facebook icon", e); + } + + if (!isValidIconResponse(bytes, contentType)) { + return null; + } + return new Favicon(bytes, contentType); + } + + private String extractUserName(String url) { + URI uri; + try { + uri = new URI(url); + } catch (URISyntaxException e) { + log.debug("could not parse url", e); + return null; + } + + List params = new URIBuilder(uri).getQueryParams(); + return params.stream().filter(p -> "id".equals(p.getName())).map(NameValuePair::getValue).findFirst().orElse(null); + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/favicon/Favicon.java b/commafeed-server/src/main/java/com/commafeed/backend/favicon/Favicon.java index e8d185c8..f9a7bd6e 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/favicon/Favicon.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/favicon/Favicon.java @@ -1,31 +1,31 @@ -package com.commafeed.backend.favicon; - -import jakarta.ws.rs.core.MediaType; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@RequiredArgsConstructor -@Getter -@Slf4j -public class Favicon { - - private static final MediaType DEFAULT_MEDIA_TYPE = MediaType.valueOf("image/x-icon"); - - private final byte[] icon; - private final MediaType mediaType; - - public Favicon(byte[] icon, String contentType) { - this(icon, parseMediaType(contentType)); - } - - private static MediaType parseMediaType(String contentType) { - try { - return MediaType.valueOf(contentType); - } catch (Exception e) { - log.debug("invalid content type '{}' received, returning default value", contentType); - return DEFAULT_MEDIA_TYPE; - } - } +package com.commafeed.backend.favicon; + +import jakarta.ws.rs.core.MediaType; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Getter +@Slf4j +public class Favicon { + + private static final MediaType DEFAULT_MEDIA_TYPE = MediaType.valueOf("image/x-icon"); + + private final byte[] icon; + private final MediaType mediaType; + + public Favicon(byte[] icon, String contentType) { + this(icon, parseMediaType(contentType)); + } + + private static MediaType parseMediaType(String contentType) { + try { + return MediaType.valueOf(contentType); + } catch (Exception e) { + log.debug("invalid content type '{}' received, returning default value", contentType); + return DEFAULT_MEDIA_TYPE; + } + } } \ No newline at end of file diff --git a/commafeed-server/src/main/java/com/commafeed/backend/favicon/YoutubeFaviconFetcher.java b/commafeed-server/src/main/java/com/commafeed/backend/favicon/YoutubeFaviconFetcher.java index 97cafbb7..7b0db9ce 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/favicon/YoutubeFaviconFetcher.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/favicon/YoutubeFaviconFetcher.java @@ -1,135 +1,135 @@ -package com.commafeed.backend.favicon; - -import java.io.IOException; -import java.net.URI; -import java.util.List; -import java.util.Optional; - -import jakarta.inject.Singleton; -import jakarta.ws.rs.core.UriBuilder; - -import org.apache.commons.lang3.ArrayUtils; -import org.apache.hc.core5.http.NameValuePair; -import org.apache.hc.core5.net.URIBuilder; - -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.HttpGetter; -import com.commafeed.backend.HttpGetter.HostNotAllowedException; -import com.commafeed.backend.HttpGetter.HttpResult; -import com.commafeed.backend.HttpGetter.NotModifiedException; -import com.commafeed.backend.HttpGetter.SchemeNotAllowedException; -import com.commafeed.backend.HttpGetter.TooManyRequestsException; -import com.commafeed.backend.model.Feed; -import com.fasterxml.jackson.core.JsonPointer; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@RequiredArgsConstructor -@Singleton -public class YoutubeFaviconFetcher extends AbstractFaviconFetcher { - - 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 final HttpGetter getter; - private final CommaFeedConfiguration config; - private final ObjectMapper objectMapper; - - @Override - public Favicon fetch(Feed feed) { - String url = feed.getUrl(); - if (!url.toLowerCase().contains("youtube.com/feeds/videos.xml")) { - return null; - } - - Optional googleAuthKey = config.googleAuthKey(); - if (googleAuthKey.isEmpty()) { - log.debug("no google auth key configured"); - return null; - } - - byte[] bytes = null; - String contentType = null; - try { - List params = new URIBuilder(url).getQueryParams(); - Optional userId = params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("user")).findFirst(); - Optional channelId = params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("channel_id")).findFirst(); - Optional playlistId = params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("playlist_id")).findFirst(); - - byte[] response = null; - if (userId.isPresent()) { - log.debug("contacting youtube api for user {}", userId.get().getValue()); - response = fetchForUser(googleAuthKey.get(), userId.get().getValue()); - } else if (channelId.isPresent()) { - log.debug("contacting youtube api for channel {}", channelId.get().getValue()); - response = fetchForChannel(googleAuthKey.get(), channelId.get().getValue()); - } else if (playlistId.isPresent()) { - log.debug("contacting youtube api for playlist {}", playlistId.get().getValue()); - response = fetchForPlaylist(googleAuthKey.get(), playlistId.get().getValue()); - } - if (ArrayUtils.isEmpty(response)) { - log.debug("youtube api returned empty response"); - return null; - } - - JsonNode thumbnailUrl = objectMapper.readTree(response).at(CHANNEL_THUMBNAIL_URL); - if (thumbnailUrl.isMissingNode()) { - log.debug("youtube api returned invalid response"); - return null; - } - - HttpResult iconResult = getter.get(thumbnailUrl.asText()); - bytes = iconResult.getContent(); - contentType = iconResult.getContentType(); - } catch (Exception e) { - log.error("Failed to retrieve YouTube icon", e); - } - - if (!isValidIconResponse(bytes, contentType)) { - return null; - } - return new Favicon(bytes, contentType); - } - - private byte[] fetchForUser(String googleAuthKey, String userId) - throws IOException, NotModifiedException, TooManyRequestsException, HostNotAllowedException, SchemeNotAllowedException { - URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels") - .queryParam("part", "snippet") - .queryParam("key", googleAuthKey) - .queryParam("forUsername", userId) - .build(); - return getter.get(uri.toString()).getContent(); - } - - private byte[] fetchForChannel(String googleAuthKey, String channelId) - throws IOException, NotModifiedException, TooManyRequestsException, HostNotAllowedException, SchemeNotAllowedException { - URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels") - .queryParam("part", "snippet") - .queryParam("key", googleAuthKey) - .queryParam("id", channelId) - .build(); - return getter.get(uri.toString()).getContent(); - } - - private byte[] fetchForPlaylist(String googleAuthKey, String playlistId) - throws IOException, NotModifiedException, TooManyRequestsException, HostNotAllowedException, SchemeNotAllowedException { - URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/playlists") - .queryParam("part", "snippet") - .queryParam("key", googleAuthKey) - .queryParam("id", playlistId) - .build(); - byte[] playlistBytes = getter.get(uri.toString()).getContent(); - - JsonNode channelId = objectMapper.readTree(playlistBytes).at(PLAYLIST_CHANNEL_ID); - if (channelId.isMissingNode()) { - return null; - } - - return fetchForChannel(googleAuthKey, channelId.asText()); - } - -} +package com.commafeed.backend.favicon; + +import java.io.IOException; +import java.net.URI; +import java.util.List; +import java.util.Optional; + +import jakarta.inject.Singleton; +import jakarta.ws.rs.core.UriBuilder; + +import org.apache.commons.lang3.ArrayUtils; +import org.apache.hc.core5.http.NameValuePair; +import org.apache.hc.core5.net.URIBuilder; + +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.backend.HttpGetter; +import com.commafeed.backend.HttpGetter.HostNotAllowedException; +import com.commafeed.backend.HttpGetter.HttpResult; +import com.commafeed.backend.HttpGetter.NotModifiedException; +import com.commafeed.backend.HttpGetter.SchemeNotAllowedException; +import com.commafeed.backend.HttpGetter.TooManyRequestsException; +import com.commafeed.backend.model.Feed; +import com.fasterxml.jackson.core.JsonPointer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Singleton +public class YoutubeFaviconFetcher extends AbstractFaviconFetcher { + + 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 final HttpGetter getter; + private final CommaFeedConfiguration config; + private final ObjectMapper objectMapper; + + @Override + public Favicon fetch(Feed feed) { + String url = feed.getUrl(); + if (!url.toLowerCase().contains("youtube.com/feeds/videos.xml")) { + return null; + } + + Optional googleAuthKey = config.googleAuthKey(); + if (googleAuthKey.isEmpty()) { + log.debug("no google auth key configured"); + return null; + } + + byte[] bytes = null; + String contentType = null; + try { + List params = new URIBuilder(url).getQueryParams(); + Optional userId = params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("user")).findFirst(); + Optional channelId = params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("channel_id")).findFirst(); + Optional playlistId = params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("playlist_id")).findFirst(); + + byte[] response = null; + if (userId.isPresent()) { + log.debug("contacting youtube api for user {}", userId.get().getValue()); + response = fetchForUser(googleAuthKey.get(), userId.get().getValue()); + } else if (channelId.isPresent()) { + log.debug("contacting youtube api for channel {}", channelId.get().getValue()); + response = fetchForChannel(googleAuthKey.get(), channelId.get().getValue()); + } else if (playlistId.isPresent()) { + log.debug("contacting youtube api for playlist {}", playlistId.get().getValue()); + response = fetchForPlaylist(googleAuthKey.get(), playlistId.get().getValue()); + } + if (ArrayUtils.isEmpty(response)) { + log.debug("youtube api returned empty response"); + return null; + } + + JsonNode thumbnailUrl = objectMapper.readTree(response).at(CHANNEL_THUMBNAIL_URL); + if (thumbnailUrl.isMissingNode()) { + log.debug("youtube api returned invalid response"); + return null; + } + + HttpResult iconResult = getter.get(thumbnailUrl.asText()); + bytes = iconResult.getContent(); + contentType = iconResult.getContentType(); + } catch (Exception e) { + log.error("Failed to retrieve YouTube icon", e); + } + + if (!isValidIconResponse(bytes, contentType)) { + return null; + } + return new Favicon(bytes, contentType); + } + + private byte[] fetchForUser(String googleAuthKey, String userId) + throws IOException, NotModifiedException, TooManyRequestsException, HostNotAllowedException, SchemeNotAllowedException { + URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels") + .queryParam("part", "snippet") + .queryParam("key", googleAuthKey) + .queryParam("forUsername", userId) + .build(); + return getter.get(uri.toString()).getContent(); + } + + private byte[] fetchForChannel(String googleAuthKey, String channelId) + throws IOException, NotModifiedException, TooManyRequestsException, HostNotAllowedException, SchemeNotAllowedException { + URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels") + .queryParam("part", "snippet") + .queryParam("key", googleAuthKey) + .queryParam("id", channelId) + .build(); + return getter.get(uri.toString()).getContent(); + } + + private byte[] fetchForPlaylist(String googleAuthKey, String playlistId) + throws IOException, NotModifiedException, TooManyRequestsException, HostNotAllowedException, SchemeNotAllowedException { + URI uri = UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/playlists") + .queryParam("part", "snippet") + .queryParam("key", googleAuthKey) + .queryParam("id", playlistId) + .build(); + byte[] playlistBytes = getter.get(uri.toString()).getContent(); + + JsonNode channelId = objectMapper.readTree(playlistBytes).at(PLAYLIST_CHANNEL_ID); + if (channelId.isMissingNode()) { + return null; + } + + return fetchForChannel(googleAuthKey, channelId.asText()); + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedEntryKeyword.java b/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedEntryKeyword.java index b82af2a2..72ffb3b3 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedEntryKeyword.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedEntryKeyword.java @@ -1,39 +1,39 @@ -package com.commafeed.backend.feed; - -import java.util.ArrayList; -import java.util.List; - -import org.apache.commons.lang3.StringUtils; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -/** - * A keyword used in a search query - */ -@Getter -@RequiredArgsConstructor -public class FeedEntryKeyword { - - public enum Mode { - INCLUDE, EXCLUDE - } - - private final String keyword; - private final Mode mode; - - public static List fromQueryString(String keywords) { - List list = new ArrayList<>(); - if (keywords != null) { - for (String keyword : StringUtils.split(keywords)) { - boolean not = false; - if (keyword.startsWith("-") || keyword.startsWith("!")) { - not = true; - keyword = keyword.substring(1); - } - list.add(new FeedEntryKeyword(keyword, not ? Mode.EXCLUDE : Mode.INCLUDE)); - } - } - return list; - } -} +package com.commafeed.backend.feed; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * A keyword used in a search query + */ +@Getter +@RequiredArgsConstructor +public class FeedEntryKeyword { + + public enum Mode { + INCLUDE, EXCLUDE + } + + private final String keyword; + private final Mode mode; + + public static List fromQueryString(String keywords) { + List list = new ArrayList<>(); + if (keywords != null) { + for (String keyword : StringUtils.split(keywords)) { + boolean not = false; + if (keyword.startsWith("-") || keyword.startsWith("!")) { + not = true; + keyword = keyword.substring(1); + } + list.add(new FeedEntryKeyword(keyword, not ? Mode.EXCLUDE : Mode.INCLUDE)); + } + } + return list; + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedFetcher.java b/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedFetcher.java index 6db57c39..3b98d4f5 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedFetcher.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedFetcher.java @@ -1,123 +1,123 @@ -package com.commafeed.backend.feed; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.time.Instant; -import java.util.List; - -import jakarta.inject.Singleton; - -import org.apache.commons.lang3.StringUtils; - -import com.commafeed.backend.Digests; -import com.commafeed.backend.HttpGetter; -import com.commafeed.backend.HttpGetter.HostNotAllowedException; -import com.commafeed.backend.HttpGetter.HttpRequest; -import com.commafeed.backend.HttpGetter.HttpResult; -import com.commafeed.backend.HttpGetter.NotModifiedException; -import com.commafeed.backend.HttpGetter.SchemeNotAllowedException; -import com.commafeed.backend.HttpGetter.TooManyRequestsException; -import com.commafeed.backend.feed.parser.FeedParser; -import com.commafeed.backend.feed.parser.FeedParser.FeedParsingException; -import com.commafeed.backend.feed.parser.FeedParserResult; -import com.commafeed.backend.urlprovider.FeedURLProvider; - -import io.quarkus.arc.All; -import lombok.extern.slf4j.Slf4j; - -/** - * Fetches a feed then parses it - */ -@Slf4j -@Singleton -public class FeedFetcher { - - private final FeedParser parser; - private final HttpGetter getter; - private final List urlProviders; - - public FeedFetcher(FeedParser parser, HttpGetter getter, @All List urlProviders) { - this.parser = parser; - this.getter = getter; - this.urlProviders = urlProviders; - } - - public FeedFetcherResult fetch(String feedUrl, boolean extractFeedUrlFromHtml, String lastModified, String eTag, - Instant lastPublishedDate, String lastContentHash) throws FeedParsingException, IOException, NotModifiedException, - TooManyRequestsException, SchemeNotAllowedException, HostNotAllowedException, NoFeedFoundException { - log.debug("Fetching feed {}", feedUrl); - - HttpResult result = getter.get(HttpRequest.builder(feedUrl).lastModified(lastModified).eTag(eTag).build()); - byte[] content = result.getContent(); - - FeedParserResult parserResult; - try { - parserResult = parser.parse(result.getUrlAfterRedirect(), content); - } catch (FeedParsingException e) { - if (extractFeedUrlFromHtml) { - String extractedUrl = extractFeedUrl(urlProviders, feedUrl, new String(result.getContent(), StandardCharsets.UTF_8)); - if (StringUtils.isNotBlank(extractedUrl)) { - feedUrl = extractedUrl; - - result = getter.get(HttpRequest.builder(extractedUrl).lastModified(lastModified).eTag(eTag).build()); - content = result.getContent(); - parserResult = parser.parse(result.getUrlAfterRedirect(), content); - } else { - throw new NoFeedFoundException(e); - } - } else { - throw e; - } - } - - if (content == null) { - throw new IOException("Feed content is empty."); - } - - boolean lastModifiedHeaderValueChanged = !StringUtils.equals(lastModified, result.getLastModifiedSince()); - boolean etagHeaderValueChanged = !StringUtils.equals(eTag, result.getETag()); - - String hash = Digests.sha1Hex(content); - if (lastContentHash != null && lastContentHash.equals(hash)) { - log.debug("content hash not modified: {}", feedUrl); - throw new NotModifiedException("content hash not modified", - lastModifiedHeaderValueChanged ? result.getLastModifiedSince() : null, - etagHeaderValueChanged ? result.getETag() : null); - } - - if (lastPublishedDate != null && lastPublishedDate.equals(parserResult.lastPublishedDate())) { - log.debug("publishedDate not modified: {}", feedUrl); - throw new NotModifiedException("publishedDate not modified", - lastModifiedHeaderValueChanged ? result.getLastModifiedSince() : null, - etagHeaderValueChanged ? result.getETag() : null); - } - - return new FeedFetcherResult(parserResult, result.getUrlAfterRedirect(), result.getLastModifiedSince(), result.getETag(), hash, - result.getValidFor()); - } - - private static String extractFeedUrl(List urlProviders, String url, String urlContent) { - for (FeedURLProvider urlProvider : urlProviders) { - String feedUrl = urlProvider.get(url, urlContent); - if (feedUrl != null) { - return feedUrl; - } - } - - return null; - } - - public record FeedFetcherResult(FeedParserResult feed, String urlAfterRedirect, String lastModifiedHeader, String lastETagHeader, - String contentHash, Duration validFor) { - } - - public static class NoFeedFoundException extends Exception { - private static final long serialVersionUID = 1L; - - public NoFeedFoundException(Throwable cause) { - super("This URL does not point to an RSS feed or a website with an RSS feed.", cause); - } - } - -} +package com.commafeed.backend.feed; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import jakarta.inject.Singleton; + +import org.apache.commons.lang3.StringUtils; + +import com.commafeed.backend.Digests; +import com.commafeed.backend.HttpGetter; +import com.commafeed.backend.HttpGetter.HostNotAllowedException; +import com.commafeed.backend.HttpGetter.HttpRequest; +import com.commafeed.backend.HttpGetter.HttpResult; +import com.commafeed.backend.HttpGetter.NotModifiedException; +import com.commafeed.backend.HttpGetter.SchemeNotAllowedException; +import com.commafeed.backend.HttpGetter.TooManyRequestsException; +import com.commafeed.backend.feed.parser.FeedParser; +import com.commafeed.backend.feed.parser.FeedParser.FeedParsingException; +import com.commafeed.backend.feed.parser.FeedParserResult; +import com.commafeed.backend.urlprovider.FeedURLProvider; + +import io.quarkus.arc.All; +import lombok.extern.slf4j.Slf4j; + +/** + * Fetches a feed then parses it + */ +@Slf4j +@Singleton +public class FeedFetcher { + + private final FeedParser parser; + private final HttpGetter getter; + private final List urlProviders; + + public FeedFetcher(FeedParser parser, HttpGetter getter, @All List urlProviders) { + this.parser = parser; + this.getter = getter; + this.urlProviders = urlProviders; + } + + public FeedFetcherResult fetch(String feedUrl, boolean extractFeedUrlFromHtml, String lastModified, String eTag, + Instant lastPublishedDate, String lastContentHash) throws FeedParsingException, IOException, NotModifiedException, + TooManyRequestsException, SchemeNotAllowedException, HostNotAllowedException, NoFeedFoundException { + log.debug("Fetching feed {}", feedUrl); + + HttpResult result = getter.get(HttpRequest.builder(feedUrl).lastModified(lastModified).eTag(eTag).build()); + byte[] content = result.getContent(); + + FeedParserResult parserResult; + try { + parserResult = parser.parse(result.getUrlAfterRedirect(), content); + } catch (FeedParsingException e) { + if (extractFeedUrlFromHtml) { + String extractedUrl = extractFeedUrl(urlProviders, feedUrl, new String(result.getContent(), StandardCharsets.UTF_8)); + if (StringUtils.isNotBlank(extractedUrl)) { + feedUrl = extractedUrl; + + result = getter.get(HttpRequest.builder(extractedUrl).lastModified(lastModified).eTag(eTag).build()); + content = result.getContent(); + parserResult = parser.parse(result.getUrlAfterRedirect(), content); + } else { + throw new NoFeedFoundException(e); + } + } else { + throw e; + } + } + + if (content == null) { + throw new IOException("Feed content is empty."); + } + + boolean lastModifiedHeaderValueChanged = !StringUtils.equals(lastModified, result.getLastModifiedSince()); + boolean etagHeaderValueChanged = !StringUtils.equals(eTag, result.getETag()); + + String hash = Digests.sha1Hex(content); + if (lastContentHash != null && lastContentHash.equals(hash)) { + log.debug("content hash not modified: {}", feedUrl); + throw new NotModifiedException("content hash not modified", + lastModifiedHeaderValueChanged ? result.getLastModifiedSince() : null, + etagHeaderValueChanged ? result.getETag() : null); + } + + if (lastPublishedDate != null && lastPublishedDate.equals(parserResult.lastPublishedDate())) { + log.debug("publishedDate not modified: {}", feedUrl); + throw new NotModifiedException("publishedDate not modified", + lastModifiedHeaderValueChanged ? result.getLastModifiedSince() : null, + etagHeaderValueChanged ? result.getETag() : null); + } + + return new FeedFetcherResult(parserResult, result.getUrlAfterRedirect(), result.getLastModifiedSince(), result.getETag(), hash, + result.getValidFor()); + } + + private static String extractFeedUrl(List urlProviders, String url, String urlContent) { + for (FeedURLProvider urlProvider : urlProviders) { + String feedUrl = urlProvider.get(url, urlContent); + if (feedUrl != null) { + return feedUrl; + } + } + + return null; + } + + public record FeedFetcherResult(FeedParserResult feed, String urlAfterRedirect, String lastModifiedHeader, String lastETagHeader, + String contentHash, Duration validFor) { + } + + public static class NoFeedFoundException extends Exception { + private static final long serialVersionUID = 1L; + + public NoFeedFoundException(Throwable cause) { + super("This URL does not point to an RSS feed or a website with an RSS feed.", cause); + } + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedRefreshEngine.java b/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedRefreshEngine.java index 661ce92f..43d593f3 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedRefreshEngine.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedRefreshEngine.java @@ -1,214 +1,214 @@ -package com.commafeed.backend.feed; - -import java.time.Instant; -import java.util.List; -import java.util.concurrent.BlockingDeque; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.LinkedBlockingDeque; -import java.util.concurrent.SynchronousQueue; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; - -import jakarta.inject.Singleton; - -import com.codahale.metrics.Gauge; -import com.codahale.metrics.Meter; -import com.codahale.metrics.MetricRegistry; -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.dao.FeedDAO; -import com.commafeed.backend.dao.UnitOfWork; -import com.commafeed.backend.model.AbstractModel; -import com.commafeed.backend.model.Feed; - -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Singleton -public class FeedRefreshEngine { - - private final UnitOfWork unitOfWork; - private final FeedDAO feedDAO; - private final FeedRefreshWorker worker; - private final FeedRefreshUpdater updater; - private final CommaFeedConfiguration config; - private final Meter refill; - - private final BlockingDeque queue; - - private final ExecutorService feedProcessingLoopExecutor; - private final ExecutorService refillLoopExecutor; - private final ExecutorService refillExecutor; - private final ThreadPoolExecutor workerExecutor; - private final ThreadPoolExecutor databaseUpdaterExecutor; - - public FeedRefreshEngine(UnitOfWork unitOfWork, FeedDAO feedDAO, FeedRefreshWorker worker, FeedRefreshUpdater updater, - CommaFeedConfiguration config, MetricRegistry metrics) { - this.unitOfWork = unitOfWork; - this.feedDAO = feedDAO; - this.worker = worker; - this.updater = updater; - this.config = config; - this.refill = metrics.meter(MetricRegistry.name(getClass(), "refill")); - - this.queue = new LinkedBlockingDeque<>(); - - this.feedProcessingLoopExecutor = Executors.newSingleThreadExecutor(); - this.refillLoopExecutor = Executors.newSingleThreadExecutor(); - this.refillExecutor = newDiscardingSingleThreadExecutorService(); - this.workerExecutor = newBlockingExecutorService(config.feedRefresh().httpThreads()); - this.databaseUpdaterExecutor = newBlockingExecutorService(config.feedRefresh().databaseThreads()); - - metrics.register(MetricRegistry.name(getClass(), "queue", "size"), (Gauge) queue::size); - metrics.register(MetricRegistry.name(getClass(), "worker", "active"), (Gauge) workerExecutor::getActiveCount); - metrics.register(MetricRegistry.name(getClass(), "updater", "active"), (Gauge) databaseUpdaterExecutor::getActiveCount); - } - - public void start() { - startFeedProcessingLoop(); - startRefillLoop(); - } - - private void startFeedProcessingLoop() { - // take a feed from the queue, process it, rince, repeat - feedProcessingLoopExecutor.submit(() -> { - while (!feedProcessingLoopExecutor.isShutdown()) { - try { - // take() is blocking until a feed is available from the queue - Feed feed = queue.take(); - - // send the feed to be processed - log.debug("got feed {} from the queue, send it for processing", feed.getId()); - processFeedAsync(feed); - - // we removed a feed from the queue, try to refill it as it may now be empty - if (queue.isEmpty()) { - log.debug("took the last feed from the queue, try to refill"); - refillQueueAsync(); - } - } catch (InterruptedException e) { - log.debug("interrupted while waiting for a feed in the queue"); - Thread.currentThread().interrupt(); - } catch (Exception e) { - log.error(e.getMessage(), e); - } - } - }); - } - - private void startRefillLoop() { - // refill the queue at regular intervals if it's empty - refillLoopExecutor.submit(() -> { - while (!refillLoopExecutor.isShutdown()) { - try { - if (queue.isEmpty()) { - log.debug("refilling queue"); - refillQueueAsync(); - } - - log.debug("sleeping for 15s"); - TimeUnit.SECONDS.sleep(15); - } catch (InterruptedException e) { - log.debug("interrupted while sleeping"); - Thread.currentThread().interrupt(); - } catch (Exception e) { - log.error(e.getMessage(), e); - } - } - }); - } - - public void refreshImmediately(Feed feed) { - 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 - queue.removeIf(f -> f.getId().equals(feed.getId())); - queue.addFirst(feed); - } - - private void refillQueueAsync() { - CompletableFuture.runAsync(() -> { - if (!queue.isEmpty()) { - return; - } - - refill.mark(); - - List nextUpdatableFeeds = getNextUpdatableFeeds(getBatchSize()); - log.debug("found {} feeds that are up for refresh", nextUpdatableFeeds.size()); - for (Feed feed : nextUpdatableFeeds) { - // add the feed only if it was not already queued - if (queue.stream().noneMatch(f -> f.getId().equals(feed.getId()))) { - queue.addLast(feed); - } - } - }, refillExecutor).whenComplete((data, ex) -> { - if (ex != null) { - log.error("error while refilling the queue", ex); - } - }); - } - - private void processFeedAsync(Feed feed) { - CompletableFuture.supplyAsync(() -> worker.update(feed), workerExecutor) - .thenApplyAsync(r -> updater.update(r.feed(), r.entries()), databaseUpdaterExecutor) - .whenComplete((data, ex) -> { - if (ex != null) { - log.error("error while processing feed {}", feed.getUrl(), ex); - } - }); - } - - private List getNextUpdatableFeeds(int max) { - return unitOfWork.call(() -> { - Instant lastLoginThreshold = config.feedRefresh().userInactivityPeriod().isZero() ? null - : Instant.now().minus(config.feedRefresh().userInactivityPeriod()); - List feeds = feedDAO.findNextUpdatable(max, lastLoginThreshold); - // update disabledUntil to prevent feeds from being returned again by feedDAO.findNextUpdatable() - Instant nextUpdateDate = Instant.now().plus(config.feedRefresh().interval()); - feedDAO.setDisabledUntil(feeds.stream().map(AbstractModel::getId).toList(), nextUpdateDate); - return feeds; - }); - } - - private int getBatchSize() { - return Math.min(100, 3 * config.feedRefresh().httpThreads()); - } - - public void stop() { - this.feedProcessingLoopExecutor.shutdownNow(); - this.refillLoopExecutor.shutdownNow(); - this.refillExecutor.shutdownNow(); - this.workerExecutor.shutdownNow(); - this.databaseUpdaterExecutor.shutdownNow(); - } - - /** - * returns an ExecutorService with a single thread that discards tasks if a task is already running - */ - private ThreadPoolExecutor newDiscardingSingleThreadExecutorService() { - ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>()); - pool.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); - return pool; - } - - /** - * returns an ExecutorService that blocks submissions until a thread is available - */ - private ThreadPoolExecutor newBlockingExecutorService(int threads) { - ThreadPoolExecutor pool = new ThreadPoolExecutor(threads, threads, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>()); - pool.setRejectedExecutionHandler((r, e) -> { - if (e.isShutdown()) { - return; - } - - try { - e.getQueue().put(r); - } catch (InterruptedException ex) { - log.debug("interrupted while waiting for a slot in the queue.", ex); - Thread.currentThread().interrupt(); - } - }); - return pool; - } -} +package com.commafeed.backend.feed; + +import java.time.Instant; +import java.util.List; +import java.util.concurrent.BlockingDeque; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Singleton; + +import com.codahale.metrics.Gauge; +import com.codahale.metrics.Meter; +import com.codahale.metrics.MetricRegistry; +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.backend.dao.FeedDAO; +import com.commafeed.backend.dao.UnitOfWork; +import com.commafeed.backend.model.AbstractModel; +import com.commafeed.backend.model.Feed; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Singleton +public class FeedRefreshEngine { + + private final UnitOfWork unitOfWork; + private final FeedDAO feedDAO; + private final FeedRefreshWorker worker; + private final FeedRefreshUpdater updater; + private final CommaFeedConfiguration config; + private final Meter refill; + + private final BlockingDeque queue; + + private final ExecutorService feedProcessingLoopExecutor; + private final ExecutorService refillLoopExecutor; + private final ExecutorService refillExecutor; + private final ThreadPoolExecutor workerExecutor; + private final ThreadPoolExecutor databaseUpdaterExecutor; + + public FeedRefreshEngine(UnitOfWork unitOfWork, FeedDAO feedDAO, FeedRefreshWorker worker, FeedRefreshUpdater updater, + CommaFeedConfiguration config, MetricRegistry metrics) { + this.unitOfWork = unitOfWork; + this.feedDAO = feedDAO; + this.worker = worker; + this.updater = updater; + this.config = config; + this.refill = metrics.meter(MetricRegistry.name(getClass(), "refill")); + + this.queue = new LinkedBlockingDeque<>(); + + this.feedProcessingLoopExecutor = Executors.newSingleThreadExecutor(); + this.refillLoopExecutor = Executors.newSingleThreadExecutor(); + this.refillExecutor = newDiscardingSingleThreadExecutorService(); + this.workerExecutor = newBlockingExecutorService(config.feedRefresh().httpThreads()); + this.databaseUpdaterExecutor = newBlockingExecutorService(config.feedRefresh().databaseThreads()); + + metrics.register(MetricRegistry.name(getClass(), "queue", "size"), (Gauge) queue::size); + metrics.register(MetricRegistry.name(getClass(), "worker", "active"), (Gauge) workerExecutor::getActiveCount); + metrics.register(MetricRegistry.name(getClass(), "updater", "active"), (Gauge) databaseUpdaterExecutor::getActiveCount); + } + + public void start() { + startFeedProcessingLoop(); + startRefillLoop(); + } + + private void startFeedProcessingLoop() { + // take a feed from the queue, process it, rince, repeat + feedProcessingLoopExecutor.submit(() -> { + while (!feedProcessingLoopExecutor.isShutdown()) { + try { + // take() is blocking until a feed is available from the queue + Feed feed = queue.take(); + + // send the feed to be processed + log.debug("got feed {} from the queue, send it for processing", feed.getId()); + processFeedAsync(feed); + + // we removed a feed from the queue, try to refill it as it may now be empty + if (queue.isEmpty()) { + log.debug("took the last feed from the queue, try to refill"); + refillQueueAsync(); + } + } catch (InterruptedException e) { + log.debug("interrupted while waiting for a feed in the queue"); + Thread.currentThread().interrupt(); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + }); + } + + private void startRefillLoop() { + // refill the queue at regular intervals if it's empty + refillLoopExecutor.submit(() -> { + while (!refillLoopExecutor.isShutdown()) { + try { + if (queue.isEmpty()) { + log.debug("refilling queue"); + refillQueueAsync(); + } + + log.debug("sleeping for 15s"); + TimeUnit.SECONDS.sleep(15); + } catch (InterruptedException e) { + log.debug("interrupted while sleeping"); + Thread.currentThread().interrupt(); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + }); + } + + public void refreshImmediately(Feed feed) { + 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 + queue.removeIf(f -> f.getId().equals(feed.getId())); + queue.addFirst(feed); + } + + private void refillQueueAsync() { + CompletableFuture.runAsync(() -> { + if (!queue.isEmpty()) { + return; + } + + refill.mark(); + + List nextUpdatableFeeds = getNextUpdatableFeeds(getBatchSize()); + log.debug("found {} feeds that are up for refresh", nextUpdatableFeeds.size()); + for (Feed feed : nextUpdatableFeeds) { + // add the feed only if it was not already queued + if (queue.stream().noneMatch(f -> f.getId().equals(feed.getId()))) { + queue.addLast(feed); + } + } + }, refillExecutor).whenComplete((data, ex) -> { + if (ex != null) { + log.error("error while refilling the queue", ex); + } + }); + } + + private void processFeedAsync(Feed feed) { + CompletableFuture.supplyAsync(() -> worker.update(feed), workerExecutor) + .thenApplyAsync(r -> updater.update(r.feed(), r.entries()), databaseUpdaterExecutor) + .whenComplete((data, ex) -> { + if (ex != null) { + log.error("error while processing feed {}", feed.getUrl(), ex); + } + }); + } + + private List getNextUpdatableFeeds(int max) { + return unitOfWork.call(() -> { + Instant lastLoginThreshold = config.feedRefresh().userInactivityPeriod().isZero() ? null + : Instant.now().minus(config.feedRefresh().userInactivityPeriod()); + List feeds = feedDAO.findNextUpdatable(max, lastLoginThreshold); + // update disabledUntil to prevent feeds from being returned again by feedDAO.findNextUpdatable() + Instant nextUpdateDate = Instant.now().plus(config.feedRefresh().interval()); + feedDAO.setDisabledUntil(feeds.stream().map(AbstractModel::getId).toList(), nextUpdateDate); + return feeds; + }); + } + + private int getBatchSize() { + return Math.min(100, 3 * config.feedRefresh().httpThreads()); + } + + public void stop() { + this.feedProcessingLoopExecutor.shutdownNow(); + this.refillLoopExecutor.shutdownNow(); + this.refillExecutor.shutdownNow(); + this.workerExecutor.shutdownNow(); + this.databaseUpdaterExecutor.shutdownNow(); + } + + /** + * returns an ExecutorService with a single thread that discards tasks if a task is already running + */ + private ThreadPoolExecutor newDiscardingSingleThreadExecutorService() { + ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>()); + pool.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); + return pool; + } + + /** + * returns an ExecutorService that blocks submissions until a thread is available + */ + private ThreadPoolExecutor newBlockingExecutorService(int threads) { + ThreadPoolExecutor pool = new ThreadPoolExecutor(threads, threads, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>()); + pool.setRejectedExecutionHandler((r, e) -> { + if (e.isShutdown()) { + return; + } + + try { + e.getQueue().put(r); + } catch (InterruptedException ex) { + log.debug("interrupted while waiting for a slot in the queue.", ex); + Thread.currentThread().interrupt(); + } + }); + return pool; + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedRefreshIntervalCalculator.java b/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedRefreshIntervalCalculator.java index da1e43de..80a8b67a 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedRefreshIntervalCalculator.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedRefreshIntervalCalculator.java @@ -1,84 +1,84 @@ -package com.commafeed.backend.feed; - -import java.time.Duration; -import java.time.Instant; -import java.time.InstantSource; -import java.time.temporal.ChronoUnit; - -import jakarta.inject.Singleton; - -import org.apache.commons.lang3.ObjectUtils; - -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.CommaFeedConfiguration.FeedRefreshErrorHandling; -import com.google.common.primitives.Longs; - -@Singleton -public class FeedRefreshIntervalCalculator { - - private final Duration interval; - private final Duration maxInterval; - private final boolean empirical; - private final FeedRefreshErrorHandling errorHandling; - private final InstantSource instantSource; - - public FeedRefreshIntervalCalculator(CommaFeedConfiguration config, InstantSource instantSource) { - this.interval = config.feedRefresh().interval(); - this.maxInterval = config.feedRefresh().maxInterval(); - this.empirical = config.feedRefresh().intervalEmpirical(); - this.errorHandling = config.feedRefresh().errors(); - this.instantSource = instantSource; - } - - public Instant onFetchSuccess(Instant publishedDate, Long averageEntryInterval, Duration validFor) { - Instant instant = empirical ? computeEmpiricalRefreshInterval(publishedDate, averageEntryInterval) - : instantSource.instant().plus(interval); - return constrainToBounds(ObjectUtils.max(instant, instantSource.instant().plus(validFor))); - } - - public Instant onFeedNotModified(Instant publishedDate, Long averageEntryInterval) { - return onFetchSuccess(publishedDate, averageEntryInterval, Duration.ZERO); - } - - public Instant onTooManyRequests(Instant retryAfter, int errorCount) { - return constrainToBounds(ObjectUtils.max(retryAfter, onFetchError(errorCount))); - } - - public Instant onFetchError(int errorCount) { - if (errorCount < errorHandling.retriesBeforeBackoff()) { - return constrainToBounds(instantSource.instant().plus(interval)); - } - - Duration retryInterval = errorHandling.backoffInterval().multipliedBy(errorCount - errorHandling.retriesBeforeBackoff() + 1L); - return constrainToBounds(instantSource.instant().plus(retryInterval)); - } - - private Instant computeEmpiricalRefreshInterval(Instant publishedDate, Long averageEntryInterval) { - Instant now = instantSource.instant(); - - if (publishedDate == null) { - return now.plus(maxInterval); - } - - long daysSinceLastPublication = ChronoUnit.DAYS.between(publishedDate, now); - if (daysSinceLastPublication >= 30) { - return now.plus(maxInterval); - } else if (daysSinceLastPublication >= 14) { - return now.plus(maxInterval.dividedBy(2)); - } else if (daysSinceLastPublication >= 7) { - return now.plus(maxInterval.dividedBy(4)); - } else if (averageEntryInterval != null) { - // use average time between entries to decide when to refresh next, divided by factor - int factor = 2; - long millis = Longs.constrainToRange(averageEntryInterval / factor, interval.toMillis(), maxInterval.dividedBy(4).toMillis()); - return now.plusMillis(millis); - } else { - // unknown case - return now.plus(maxInterval); - } - } - - private Instant constrainToBounds(Instant instant) { - return ObjectUtils.max(ObjectUtils.min(instant, instantSource.instant().plus(maxInterval)), instantSource.instant().plus(interval)); - } -} +package com.commafeed.backend.feed; + +import java.time.Duration; +import java.time.Instant; +import java.time.InstantSource; +import java.time.temporal.ChronoUnit; + +import jakarta.inject.Singleton; + +import org.apache.commons.lang3.ObjectUtils; + +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.CommaFeedConfiguration.FeedRefreshErrorHandling; +import com.google.common.primitives.Longs; + +@Singleton +public class FeedRefreshIntervalCalculator { + + private final Duration interval; + private final Duration maxInterval; + private final boolean empirical; + private final FeedRefreshErrorHandling errorHandling; + private final InstantSource instantSource; + + public FeedRefreshIntervalCalculator(CommaFeedConfiguration config, InstantSource instantSource) { + this.interval = config.feedRefresh().interval(); + this.maxInterval = config.feedRefresh().maxInterval(); + this.empirical = config.feedRefresh().intervalEmpirical(); + this.errorHandling = config.feedRefresh().errors(); + this.instantSource = instantSource; + } + + public Instant onFetchSuccess(Instant publishedDate, Long averageEntryInterval, Duration validFor) { + Instant instant = empirical ? computeEmpiricalRefreshInterval(publishedDate, averageEntryInterval) + : instantSource.instant().plus(interval); + return constrainToBounds(ObjectUtils.max(instant, instantSource.instant().plus(validFor))); + } + + public Instant onFeedNotModified(Instant publishedDate, Long averageEntryInterval) { + return onFetchSuccess(publishedDate, averageEntryInterval, Duration.ZERO); + } + + public Instant onTooManyRequests(Instant retryAfter, int errorCount) { + return constrainToBounds(ObjectUtils.max(retryAfter, onFetchError(errorCount))); + } + + public Instant onFetchError(int errorCount) { + if (errorCount < errorHandling.retriesBeforeBackoff()) { + return constrainToBounds(instantSource.instant().plus(interval)); + } + + Duration retryInterval = errorHandling.backoffInterval().multipliedBy(errorCount - errorHandling.retriesBeforeBackoff() + 1L); + return constrainToBounds(instantSource.instant().plus(retryInterval)); + } + + private Instant computeEmpiricalRefreshInterval(Instant publishedDate, Long averageEntryInterval) { + Instant now = instantSource.instant(); + + if (publishedDate == null) { + return now.plus(maxInterval); + } + + long daysSinceLastPublication = ChronoUnit.DAYS.between(publishedDate, now); + if (daysSinceLastPublication >= 30) { + return now.plus(maxInterval); + } else if (daysSinceLastPublication >= 14) { + return now.plus(maxInterval.dividedBy(2)); + } else if (daysSinceLastPublication >= 7) { + return now.plus(maxInterval.dividedBy(4)); + } else if (averageEntryInterval != null) { + // use average time between entries to decide when to refresh next, divided by factor + int factor = 2; + long millis = Longs.constrainToRange(averageEntryInterval / factor, interval.toMillis(), maxInterval.dividedBy(4).toMillis()); + return now.plusMillis(millis); + } else { + // unknown case + return now.plus(maxInterval); + } + } + + private Instant constrainToBounds(Instant instant) { + return ObjectUtils.max(ObjectUtils.min(instant, instantSource.instant().plus(maxInterval)), instantSource.instant().plus(interval)); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedRefreshUpdater.java b/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedRefreshUpdater.java index ad565c2a..6d910b69 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedRefreshUpdater.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedRefreshUpdater.java @@ -1,180 +1,180 @@ -package com.commafeed.backend.feed; - -import java.util.Arrays; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.Lock; - -import jakarta.inject.Singleton; - -import org.apache.commons.lang3.StringUtils; - -import com.codahale.metrics.Meter; -import com.codahale.metrics.MetricRegistry; -import com.commafeed.backend.Digests; -import com.commafeed.backend.dao.FeedSubscriptionDAO; -import com.commafeed.backend.dao.UnitOfWork; -import com.commafeed.backend.feed.parser.FeedParserResult.Content; -import com.commafeed.backend.feed.parser.FeedParserResult.Entry; -import com.commafeed.backend.model.Feed; -import com.commafeed.backend.model.FeedEntry; -import com.commafeed.backend.model.FeedSubscription; -import com.commafeed.backend.model.Models; -import com.commafeed.backend.service.FeedEntryService; -import com.commafeed.backend.service.FeedService; -import com.commafeed.frontend.ws.WebSocketMessageBuilder; -import com.commafeed.frontend.ws.WebSocketSessions; -import com.google.common.util.concurrent.Striped; - -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -/** - * Updates the feed in the database and inserts new entries - */ -@Slf4j -@Singleton -public class FeedRefreshUpdater { - - private final UnitOfWork unitOfWork; - private final FeedService feedService; - private final FeedEntryService feedEntryService; - private final FeedSubscriptionDAO feedSubscriptionDAO; - private final WebSocketSessions webSocketSessions; - - private final Striped locks; - - private final Meter feedUpdated; - private final Meter entryInserted; - - public FeedRefreshUpdater(UnitOfWork unitOfWork, FeedService feedService, FeedEntryService feedEntryService, MetricRegistry metrics, - FeedSubscriptionDAO feedSubscriptionDAO, WebSocketSessions webSocketSessions) { - this.unitOfWork = unitOfWork; - this.feedService = feedService; - this.feedEntryService = feedEntryService; - this.feedSubscriptionDAO = feedSubscriptionDAO; - this.webSocketSessions = webSocketSessions; - - locks = Striped.lazyWeakLock(100000); - - feedUpdated = metrics.meter(MetricRegistry.name(getClass(), "feedUpdated")); - entryInserted = metrics.meter(MetricRegistry.name(getClass(), "entryInserted")); - } - - private AddEntryResult addEntry(final Feed feed, final Entry entry, final List subscriptions) { - boolean processed = false; - boolean inserted = false; - Set subscriptionsForWhichEntryIsUnread = new HashSet<>(); - - // lock on feed, make sure we are not updating the same feed twice at - // the same time - String key1 = StringUtils.trimToEmpty(String.valueOf(feed.getId())); - - // lock on content, make sure we are not updating the same entry - // twice at the same time - Content content = entry.content(); - String key2 = Digests.sha1Hex(StringUtils.trimToEmpty(content.content() + content.title())); - - Iterator iterator = locks.bulkGet(Arrays.asList(key1, key2)).iterator(); - Lock lock1 = iterator.next(); - Lock lock2 = iterator.next(); - boolean locked1 = false; - boolean locked2 = false; - try { - // try to lock, give up after 1 minute - locked1 = lock1.tryLock(1, TimeUnit.MINUTES); - locked2 = lock2.tryLock(1, TimeUnit.MINUTES); - if (locked1 && locked2) { - processed = true; - inserted = unitOfWork.call(() -> { - boolean newEntry = false; - FeedEntry feedEntry = feedEntryService.find(feed, entry); - if (feedEntry == null) { - feedEntry = feedEntryService.create(feed, entry); - newEntry = true; - } - if (newEntry) { - entryInserted.mark(); - for (FeedSubscription sub : subscriptions) { - boolean unread = feedEntryService.applyFilter(sub, feedEntry); - if (unread) { - subscriptionsForWhichEntryIsUnread.add(sub); - } - } - } - return newEntry; - }); - } else { - log.error("lock timeout for {} - {}", feed.getUrl(), key1); - } - } catch (InterruptedException e) { - log.error("interrupted while waiting for lock for {} : {}", feed.getUrl(), e.getMessage(), e); - } finally { - if (locked1) { - lock1.unlock(); - } - if (locked2) { - lock2.unlock(); - } - } - return new AddEntryResult(processed, inserted, subscriptionsForWhichEntryIsUnread); - } - - public boolean update(Feed feed, List entries) { - boolean processed = true; - long inserted = 0; - Map unreadCountBySubscription = new HashMap<>(); - - if (!entries.isEmpty()) { - List subscriptions = null; - for (Entry entry : entries) { - if (subscriptions == null) { - subscriptions = unitOfWork.call(() -> feedSubscriptionDAO.findByFeed(feed)); - } - AddEntryResult addEntryResult = addEntry(feed, entry, subscriptions); - processed &= addEntryResult.processed; - inserted += addEntryResult.inserted ? 1 : 0; - addEntryResult.subscriptionsForWhichEntryIsUnread.forEach(sub -> unreadCountBySubscription.merge(sub, 1L, Long::sum)); - } - - if (inserted == 0) { - feed.setMessage("No new entries found"); - } else if (inserted > 0) { - feed.setMessage("Found %s new entries".formatted(inserted)); - } - } - - if (!processed) { - // requeue asap - feed.setDisabledUntil(Models.MINIMUM_INSTANT); - } - - if (inserted > 0) { - feedUpdated.mark(); - } - - unitOfWork.run(() -> feedService.update(feed)); - - notifyOverWebsocket(unreadCountBySubscription); - - return processed; - } - - private void notifyOverWebsocket(Map unreadCountBySubscription) { - unreadCountBySubscription.forEach((sub, unreadCount) -> webSocketSessions.sendMessage(sub.getUser(), - WebSocketMessageBuilder.newFeedEntries(sub, unreadCount))); - } - - @AllArgsConstructor - private static class AddEntryResult { - private final boolean processed; - private final boolean inserted; - private final Set subscriptionsForWhichEntryIsUnread; - } - -} +package com.commafeed.backend.feed; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; + +import jakarta.inject.Singleton; + +import org.apache.commons.lang3.StringUtils; + +import com.codahale.metrics.Meter; +import com.codahale.metrics.MetricRegistry; +import com.commafeed.backend.Digests; +import com.commafeed.backend.dao.FeedSubscriptionDAO; +import com.commafeed.backend.dao.UnitOfWork; +import com.commafeed.backend.feed.parser.FeedParserResult.Content; +import com.commafeed.backend.feed.parser.FeedParserResult.Entry; +import com.commafeed.backend.model.Feed; +import com.commafeed.backend.model.FeedEntry; +import com.commafeed.backend.model.FeedSubscription; +import com.commafeed.backend.model.Models; +import com.commafeed.backend.service.FeedEntryService; +import com.commafeed.backend.service.FeedService; +import com.commafeed.frontend.ws.WebSocketMessageBuilder; +import com.commafeed.frontend.ws.WebSocketSessions; +import com.google.common.util.concurrent.Striped; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Updates the feed in the database and inserts new entries + */ +@Slf4j +@Singleton +public class FeedRefreshUpdater { + + private final UnitOfWork unitOfWork; + private final FeedService feedService; + private final FeedEntryService feedEntryService; + private final FeedSubscriptionDAO feedSubscriptionDAO; + private final WebSocketSessions webSocketSessions; + + private final Striped locks; + + private final Meter feedUpdated; + private final Meter entryInserted; + + public FeedRefreshUpdater(UnitOfWork unitOfWork, FeedService feedService, FeedEntryService feedEntryService, MetricRegistry metrics, + FeedSubscriptionDAO feedSubscriptionDAO, WebSocketSessions webSocketSessions) { + this.unitOfWork = unitOfWork; + this.feedService = feedService; + this.feedEntryService = feedEntryService; + this.feedSubscriptionDAO = feedSubscriptionDAO; + this.webSocketSessions = webSocketSessions; + + locks = Striped.lazyWeakLock(100000); + + feedUpdated = metrics.meter(MetricRegistry.name(getClass(), "feedUpdated")); + entryInserted = metrics.meter(MetricRegistry.name(getClass(), "entryInserted")); + } + + private AddEntryResult addEntry(final Feed feed, final Entry entry, final List subscriptions) { + boolean processed = false; + boolean inserted = false; + Set subscriptionsForWhichEntryIsUnread = new HashSet<>(); + + // lock on feed, make sure we are not updating the same feed twice at + // the same time + String key1 = StringUtils.trimToEmpty(String.valueOf(feed.getId())); + + // lock on content, make sure we are not updating the same entry + // twice at the same time + Content content = entry.content(); + String key2 = Digests.sha1Hex(StringUtils.trimToEmpty(content.content() + content.title())); + + Iterator iterator = locks.bulkGet(Arrays.asList(key1, key2)).iterator(); + Lock lock1 = iterator.next(); + Lock lock2 = iterator.next(); + boolean locked1 = false; + boolean locked2 = false; + try { + // try to lock, give up after 1 minute + locked1 = lock1.tryLock(1, TimeUnit.MINUTES); + locked2 = lock2.tryLock(1, TimeUnit.MINUTES); + if (locked1 && locked2) { + processed = true; + inserted = unitOfWork.call(() -> { + boolean newEntry = false; + FeedEntry feedEntry = feedEntryService.find(feed, entry); + if (feedEntry == null) { + feedEntry = feedEntryService.create(feed, entry); + newEntry = true; + } + if (newEntry) { + entryInserted.mark(); + for (FeedSubscription sub : subscriptions) { + boolean unread = feedEntryService.applyFilter(sub, feedEntry); + if (unread) { + subscriptionsForWhichEntryIsUnread.add(sub); + } + } + } + return newEntry; + }); + } else { + log.error("lock timeout for {} - {}", feed.getUrl(), key1); + } + } catch (InterruptedException e) { + log.error("interrupted while waiting for lock for {} : {}", feed.getUrl(), e.getMessage(), e); + } finally { + if (locked1) { + lock1.unlock(); + } + if (locked2) { + lock2.unlock(); + } + } + return new AddEntryResult(processed, inserted, subscriptionsForWhichEntryIsUnread); + } + + public boolean update(Feed feed, List entries) { + boolean processed = true; + long inserted = 0; + Map unreadCountBySubscription = new HashMap<>(); + + if (!entries.isEmpty()) { + List subscriptions = null; + for (Entry entry : entries) { + if (subscriptions == null) { + subscriptions = unitOfWork.call(() -> feedSubscriptionDAO.findByFeed(feed)); + } + AddEntryResult addEntryResult = addEntry(feed, entry, subscriptions); + processed &= addEntryResult.processed; + inserted += addEntryResult.inserted ? 1 : 0; + addEntryResult.subscriptionsForWhichEntryIsUnread.forEach(sub -> unreadCountBySubscription.merge(sub, 1L, Long::sum)); + } + + if (inserted == 0) { + feed.setMessage("No new entries found"); + } else if (inserted > 0) { + feed.setMessage("Found %s new entries".formatted(inserted)); + } + } + + if (!processed) { + // requeue asap + feed.setDisabledUntil(Models.MINIMUM_INSTANT); + } + + if (inserted > 0) { + feedUpdated.mark(); + } + + unitOfWork.run(() -> feedService.update(feed)); + + notifyOverWebsocket(unreadCountBySubscription); + + return processed; + } + + private void notifyOverWebsocket(Map unreadCountBySubscription) { + unreadCountBySubscription.forEach((sub, unreadCount) -> webSocketSessions.sendMessage(sub.getUser(), + WebSocketMessageBuilder.newFeedEntries(sub, unreadCount))); + } + + @AllArgsConstructor + private static class AddEntryResult { + private final boolean processed; + private final boolean inserted; + private final Set subscriptionsForWhichEntryIsUnread; + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedRefreshWorker.java b/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedRefreshWorker.java index d375e918..252b009a 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedRefreshWorker.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedRefreshWorker.java @@ -1,125 +1,125 @@ -package com.commafeed.backend.feed; - -import java.time.Duration; -import java.time.Instant; -import java.util.Collections; -import java.util.List; -import java.util.Optional; - -import jakarta.inject.Singleton; - -import org.apache.commons.lang3.StringUtils; - -import com.codahale.metrics.Meter; -import com.codahale.metrics.MetricRegistry; -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.HttpGetter.NotModifiedException; -import com.commafeed.backend.HttpGetter.TooManyRequestsException; -import com.commafeed.backend.feed.FeedFetcher.FeedFetcherResult; -import com.commafeed.backend.feed.parser.FeedParserResult.Entry; -import com.commafeed.backend.model.Feed; - -import lombok.extern.slf4j.Slf4j; - -/** - * Calls {@link FeedFetcher} and updates the Feed object, but does not update the database, ({@link FeedRefreshUpdater} does that) - */ -@Slf4j -@Singleton -public class FeedRefreshWorker { - - private final FeedRefreshIntervalCalculator refreshIntervalCalculator; - private final FeedFetcher fetcher; - private final CommaFeedConfiguration config; - private final Meter feedFetched; - - public FeedRefreshWorker(FeedRefreshIntervalCalculator refreshIntervalCalculator, FeedFetcher fetcher, CommaFeedConfiguration config, - MetricRegistry metrics) { - this.refreshIntervalCalculator = refreshIntervalCalculator; - this.fetcher = fetcher; - this.config = config; - this.feedFetched = metrics.meter(MetricRegistry.name(getClass(), "feedFetched")); - - } - - public FeedRefreshWorkerResult update(Feed feed) { - try { - String url = Optional.ofNullable(feed.getUrlAfterRedirect()).orElse(feed.getUrl()); - FeedFetcherResult result = fetcher.fetch(url, false, feed.getLastModifiedHeader(), feed.getEtagHeader(), - feed.getLastPublishedDate(), feed.getLastContentHash()); - // stops here if NotModifiedException or any other exception is thrown - - List entries = result.feed().entries(); - - int maxFeedCapacity = config.database().cleanup().maxFeedCapacity(); - if (maxFeedCapacity > 0) { - entries = entries.stream().limit(maxFeedCapacity).toList(); - } - - Duration entriesMaxAge = config.database().cleanup().entriesMaxAge(); - if (!entriesMaxAge.isZero()) { - Instant threshold = Instant.now().minus(entriesMaxAge); - entries = entries.stream().filter(entry -> entry.published().isAfter(threshold)).toList(); - } - - String urlAfterRedirect = result.urlAfterRedirect(); - if (StringUtils.equals(url, urlAfterRedirect)) { - urlAfterRedirect = null; - } - - feed.setUrlAfterRedirect(urlAfterRedirect); - feed.setLink(result.feed().link()); - feed.setLastModifiedHeader(result.lastModifiedHeader()); - feed.setEtagHeader(result.lastETagHeader()); - feed.setLastContentHash(result.contentHash()); - feed.setLastPublishedDate(result.feed().lastPublishedDate()); - feed.setAverageEntryInterval(result.feed().averageEntryInterval()); - feed.setLastEntryDate(result.feed().lastEntryDate()); - - feed.setErrorCount(0); - feed.setMessage(null); - feed.setDisabledUntil(refreshIntervalCalculator.onFetchSuccess(result.feed().lastPublishedDate(), - result.feed().averageEntryInterval(), result.validFor())); - - return new FeedRefreshWorkerResult(feed, entries); - } catch (NotModifiedException e) { - log.debug("Feed not modified : {} - {}", feed.getUrl(), e.getMessage()); - - feed.setErrorCount(0); - feed.setMessage(e.getMessage()); - feed.setDisabledUntil(refreshIntervalCalculator.onFeedNotModified(feed.getLastPublishedDate(), feed.getAverageEntryInterval())); - - if (e.getNewLastModifiedHeader() != null) { - feed.setLastModifiedHeader(e.getNewLastModifiedHeader()); - } - - if (e.getNewEtagHeader() != null) { - feed.setEtagHeader(e.getNewEtagHeader()); - } - - return new FeedRefreshWorkerResult(feed, Collections.emptyList()); - } catch (TooManyRequestsException e) { - log.debug("Too many requests : {}", feed.getUrl()); - - feed.setErrorCount(feed.getErrorCount() + 1); - feed.setMessage("Server indicated that we are sending too many requests"); - feed.setDisabledUntil(refreshIntervalCalculator.onTooManyRequests(e.getRetryAfter(), feed.getErrorCount())); - - return new FeedRefreshWorkerResult(feed, Collections.emptyList()); - } catch (Exception e) { - log.debug("unable to refresh feed {}", feed.getUrl(), e); - - feed.setErrorCount(feed.getErrorCount() + 1); - feed.setMessage("Unable to refresh feed : " + e.getMessage()); - feed.setDisabledUntil(refreshIntervalCalculator.onFetchError(feed.getErrorCount())); - - return new FeedRefreshWorkerResult(feed, Collections.emptyList()); - } finally { - feedFetched.mark(); - } - } - - public record FeedRefreshWorkerResult(Feed feed, List entries) { - } - -} +package com.commafeed.backend.feed; + +import java.time.Duration; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import jakarta.inject.Singleton; + +import org.apache.commons.lang3.StringUtils; + +import com.codahale.metrics.Meter; +import com.codahale.metrics.MetricRegistry; +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.backend.HttpGetter.NotModifiedException; +import com.commafeed.backend.HttpGetter.TooManyRequestsException; +import com.commafeed.backend.feed.FeedFetcher.FeedFetcherResult; +import com.commafeed.backend.feed.parser.FeedParserResult.Entry; +import com.commafeed.backend.model.Feed; + +import lombok.extern.slf4j.Slf4j; + +/** + * Calls {@link FeedFetcher} and updates the Feed object, but does not update the database, ({@link FeedRefreshUpdater} does that) + */ +@Slf4j +@Singleton +public class FeedRefreshWorker { + + private final FeedRefreshIntervalCalculator refreshIntervalCalculator; + private final FeedFetcher fetcher; + private final CommaFeedConfiguration config; + private final Meter feedFetched; + + public FeedRefreshWorker(FeedRefreshIntervalCalculator refreshIntervalCalculator, FeedFetcher fetcher, CommaFeedConfiguration config, + MetricRegistry metrics) { + this.refreshIntervalCalculator = refreshIntervalCalculator; + this.fetcher = fetcher; + this.config = config; + this.feedFetched = metrics.meter(MetricRegistry.name(getClass(), "feedFetched")); + + } + + public FeedRefreshWorkerResult update(Feed feed) { + try { + String url = Optional.ofNullable(feed.getUrlAfterRedirect()).orElse(feed.getUrl()); + FeedFetcherResult result = fetcher.fetch(url, false, feed.getLastModifiedHeader(), feed.getEtagHeader(), + feed.getLastPublishedDate(), feed.getLastContentHash()); + // stops here if NotModifiedException or any other exception is thrown + + List entries = result.feed().entries(); + + int maxFeedCapacity = config.database().cleanup().maxFeedCapacity(); + if (maxFeedCapacity > 0) { + entries = entries.stream().limit(maxFeedCapacity).toList(); + } + + Duration entriesMaxAge = config.database().cleanup().entriesMaxAge(); + if (!entriesMaxAge.isZero()) { + Instant threshold = Instant.now().minus(entriesMaxAge); + entries = entries.stream().filter(entry -> entry.published().isAfter(threshold)).toList(); + } + + String urlAfterRedirect = result.urlAfterRedirect(); + if (StringUtils.equals(url, urlAfterRedirect)) { + urlAfterRedirect = null; + } + + feed.setUrlAfterRedirect(urlAfterRedirect); + feed.setLink(result.feed().link()); + feed.setLastModifiedHeader(result.lastModifiedHeader()); + feed.setEtagHeader(result.lastETagHeader()); + feed.setLastContentHash(result.contentHash()); + feed.setLastPublishedDate(result.feed().lastPublishedDate()); + feed.setAverageEntryInterval(result.feed().averageEntryInterval()); + feed.setLastEntryDate(result.feed().lastEntryDate()); + + feed.setErrorCount(0); + feed.setMessage(null); + feed.setDisabledUntil(refreshIntervalCalculator.onFetchSuccess(result.feed().lastPublishedDate(), + result.feed().averageEntryInterval(), result.validFor())); + + return new FeedRefreshWorkerResult(feed, entries); + } catch (NotModifiedException e) { + log.debug("Feed not modified : {} - {}", feed.getUrl(), e.getMessage()); + + feed.setErrorCount(0); + feed.setMessage(e.getMessage()); + feed.setDisabledUntil(refreshIntervalCalculator.onFeedNotModified(feed.getLastPublishedDate(), feed.getAverageEntryInterval())); + + if (e.getNewLastModifiedHeader() != null) { + feed.setLastModifiedHeader(e.getNewLastModifiedHeader()); + } + + if (e.getNewEtagHeader() != null) { + feed.setEtagHeader(e.getNewEtagHeader()); + } + + return new FeedRefreshWorkerResult(feed, Collections.emptyList()); + } catch (TooManyRequestsException e) { + log.debug("Too many requests : {}", feed.getUrl()); + + feed.setErrorCount(feed.getErrorCount() + 1); + feed.setMessage("Server indicated that we are sending too many requests"); + feed.setDisabledUntil(refreshIntervalCalculator.onTooManyRequests(e.getRetryAfter(), feed.getErrorCount())); + + return new FeedRefreshWorkerResult(feed, Collections.emptyList()); + } catch (Exception e) { + log.debug("unable to refresh feed {}", feed.getUrl(), e); + + feed.setErrorCount(feed.getErrorCount() + 1); + feed.setMessage("Unable to refresh feed : " + e.getMessage()); + feed.setDisabledUntil(refreshIntervalCalculator.onFetchError(feed.getErrorCount())); + + return new FeedRefreshWorkerResult(feed, Collections.emptyList()); + } finally { + feedFetched.mark(); + } + } + + public record FeedRefreshWorkerResult(Feed feed, List entries) { + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedUtils.java b/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedUtils.java index a6441402..459b05ea 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedUtils.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/feed/FeedUtils.java @@ -1,218 +1,218 @@ -package com.commafeed.backend.feed; - -import java.net.MalformedURLException; -import java.net.URL; -import java.util.Iterator; -import java.util.List; -import java.util.regex.Pattern; - -import org.apache.commons.lang3.StringUtils; -import org.apache.hc.client5.http.utils.Base64; -import org.jsoup.Jsoup; -import org.jsoup.nodes.Document; -import org.jsoup.nodes.Element; -import org.jsoup.select.Elements; -import org.netpreserve.urlcanon.Canonicalizer; -import org.netpreserve.urlcanon.ParsedUrl; - -import com.commafeed.backend.feed.FeedEntryKeyword.Mode; -import com.commafeed.backend.feed.parser.TextDirectionDetector; -import com.commafeed.backend.model.FeedSubscription; -import com.commafeed.frontend.model.Entry; - -import lombok.extern.slf4j.Slf4j; - -/** - * Utility methods related to feed handling - * - */ -@Slf4j -public class FeedUtils { - - private static final String ESCAPED_QUESTION_MARK = Pattern.quote("?"); - - public static String truncate(String string, int length) { - if (string != null) { - string = string.substring(0, Math.min(length, string.length())); - } - return string; - } - - public static boolean isHttp(String url) { - return url.startsWith("http://"); - } - - public static boolean isHttps(String url) { - return url.startsWith("https://"); - } - - public static boolean isAbsoluteUrl(String 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 - */ - public static String normalizeURL(String url) { - if (url == null) { - return null; - } - - ParsedUrl parsedUrl = ParsedUrl.parseUrl(url); - Canonicalizer.AGGRESSIVE.canonicalize(parsedUrl); - String normalized = parsedUrl.toString(); - if (normalized == null) { - normalized = url; - } - - // 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 - // duplicates - normalized = normalized.toLowerCase(); - - // store all urls as http - if (normalized.startsWith("https")) { - normalized = "http" + normalized.substring(5); - } - - // remove the www. part - normalized = normalized.replace("//www.", "//"); - - // feedproxy redirects to feedburner - normalized = normalized.replace("feedproxy.google.com", "feeds.feedburner.com"); - - // feedburner feeds have a special treatment - if (normalized.split(ESCAPED_QUESTION_MARK)[0].contains("feedburner.com")) { - normalized = normalized.replace("feeds2.feedburner.com", "feeds.feedburner.com"); - normalized = normalized.split(ESCAPED_QUESTION_MARK)[0]; - normalized = StringUtils.removeEnd(normalized, "/"); - } - - return normalized; - } - - public static boolean isRTL(String title, String content) { - String text = StringUtils.isNotBlank(content) ? content : title; - if (StringUtils.isBlank(text)) { - return false; - } - - String stripped = Jsoup.parse(text).text(); - if (StringUtils.isBlank(stripped)) { - return false; - } - - return TextDirectionDetector.detect(stripped) == TextDirectionDetector.Direction.RIGHT_TO_LEFT; - } - - public static String removeTrailingSlash(String url) { - if (url.endsWith("/")) { - url = url.substring(0, url.length() - 1); - } - return url; - } - - /** - * - * @param relativeUrl - * the url of the entry - * @param feedLink - * the url of the feed as described in the feed - * @param feedUrl - * the url of the feed that we used to fetch the feed - * @return an absolute url pointing to the entry - */ - public static String toAbsoluteUrl(String relativeUrl, String feedLink, String feedUrl) { - String baseUrl = (feedLink != null && isAbsoluteUrl(feedLink)) ? feedLink : feedUrl; - if (baseUrl == null) { - return null; - } - - try { - return new URL(new URL(baseUrl), relativeUrl).toString(); - } catch (MalformedURLException e) { - log.debug("could not parse url : {}", e.getMessage(), e); - return null; - } - } - - public static String getFaviconUrl(FeedSubscription subscription) { - return "rest/feed/favicon/" + subscription.getId(); - } - - public static String proxyImages(String content) { - if (StringUtils.isBlank(content)) { - return content; - } - - Document doc = Jsoup.parse(content); - Elements elements = doc.select("img"); - for (Element element : elements) { - String href = element.attr("src"); - if (StringUtils.isNotBlank(href)) { - String proxy = proxyImage(href); - element.attr("src", proxy); - } - } - - return doc.body().html(); - } - - public static String proxyImage(String url) { - if (StringUtils.isBlank(url)) { - return url; - } - return "rest/server/proxy?u=" + imageProxyEncoder(url); - } - - public static String rot13(String msg) { - StringBuilder message = new StringBuilder(); - - for (char c : msg.toCharArray()) { - if (c >= 'a' && c <= 'm') { - c += 13; - } else if (c >= 'n' && c <= 'z') { - c -= 13; - } else if (c >= 'A' && c <= 'M') { - c += 13; - } else if (c >= 'N' && c <= 'Z') { - c -= 13; - } - message.append(c); - } - - return message.toString(); - } - - public static String imageProxyEncoder(String url) { - return Base64.encodeBase64String(rot13(url).getBytes()); - } - - public static String imageProxyDecoder(String code) { - return rot13(new String(Base64.decodeBase64(code))); - } - - public static void removeUnwantedFromSearch(List entries, List keywords) { - Iterator it = entries.iterator(); - while (it.hasNext()) { - Entry entry = it.next(); - boolean keep = true; - for (FeedEntryKeyword keyword : keywords) { - String title = entry.getTitle() == null ? null : Jsoup.parse(entry.getTitle()).text(); - String content = entry.getContent() == null ? null : Jsoup.parse(entry.getContent()).text(); - boolean condition = !StringUtils.containsIgnoreCase(content, keyword.getKeyword()) - && !StringUtils.containsIgnoreCase(title, keyword.getKeyword()); - if (keyword.getMode() == Mode.EXCLUDE) { - condition = !condition; - } - if (condition) { - keep = false; - break; - } - } - if (!keep) { - it.remove(); - } - } - } -} +package com.commafeed.backend.feed; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Iterator; +import java.util.List; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; +import org.apache.hc.client5.http.utils.Base64; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.netpreserve.urlcanon.Canonicalizer; +import org.netpreserve.urlcanon.ParsedUrl; + +import com.commafeed.backend.feed.FeedEntryKeyword.Mode; +import com.commafeed.backend.feed.parser.TextDirectionDetector; +import com.commafeed.backend.model.FeedSubscription; +import com.commafeed.frontend.model.Entry; + +import lombok.extern.slf4j.Slf4j; + +/** + * Utility methods related to feed handling + * + */ +@Slf4j +public class FeedUtils { + + private static final String ESCAPED_QUESTION_MARK = Pattern.quote("?"); + + public static String truncate(String string, int length) { + if (string != null) { + string = string.substring(0, Math.min(length, string.length())); + } + return string; + } + + public static boolean isHttp(String url) { + return url.startsWith("http://"); + } + + public static boolean isHttps(String url) { + return url.startsWith("https://"); + } + + public static boolean isAbsoluteUrl(String 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 + */ + public static String normalizeURL(String url) { + if (url == null) { + return null; + } + + ParsedUrl parsedUrl = ParsedUrl.parseUrl(url); + Canonicalizer.AGGRESSIVE.canonicalize(parsedUrl); + String normalized = parsedUrl.toString(); + if (normalized == null) { + normalized = url; + } + + // 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 + // duplicates + normalized = normalized.toLowerCase(); + + // store all urls as http + if (normalized.startsWith("https")) { + normalized = "http" + normalized.substring(5); + } + + // remove the www. part + normalized = normalized.replace("//www.", "//"); + + // feedproxy redirects to feedburner + normalized = normalized.replace("feedproxy.google.com", "feeds.feedburner.com"); + + // feedburner feeds have a special treatment + if (normalized.split(ESCAPED_QUESTION_MARK)[0].contains("feedburner.com")) { + normalized = normalized.replace("feeds2.feedburner.com", "feeds.feedburner.com"); + normalized = normalized.split(ESCAPED_QUESTION_MARK)[0]; + normalized = StringUtils.removeEnd(normalized, "/"); + } + + return normalized; + } + + public static boolean isRTL(String title, String content) { + String text = StringUtils.isNotBlank(content) ? content : title; + if (StringUtils.isBlank(text)) { + return false; + } + + String stripped = Jsoup.parse(text).text(); + if (StringUtils.isBlank(stripped)) { + return false; + } + + return TextDirectionDetector.detect(stripped) == TextDirectionDetector.Direction.RIGHT_TO_LEFT; + } + + public static String removeTrailingSlash(String url) { + if (url.endsWith("/")) { + url = url.substring(0, url.length() - 1); + } + return url; + } + + /** + * + * @param relativeUrl + * the url of the entry + * @param feedLink + * the url of the feed as described in the feed + * @param feedUrl + * the url of the feed that we used to fetch the feed + * @return an absolute url pointing to the entry + */ + public static String toAbsoluteUrl(String relativeUrl, String feedLink, String feedUrl) { + String baseUrl = (feedLink != null && isAbsoluteUrl(feedLink)) ? feedLink : feedUrl; + if (baseUrl == null) { + return null; + } + + try { + return new URL(new URL(baseUrl), relativeUrl).toString(); + } catch (MalformedURLException e) { + log.debug("could not parse url : {}", e.getMessage(), e); + return null; + } + } + + public static String getFaviconUrl(FeedSubscription subscription) { + return "rest/feed/favicon/" + subscription.getId(); + } + + public static String proxyImages(String content) { + if (StringUtils.isBlank(content)) { + return content; + } + + Document doc = Jsoup.parse(content); + Elements elements = doc.select("img"); + for (Element element : elements) { + String href = element.attr("src"); + if (StringUtils.isNotBlank(href)) { + String proxy = proxyImage(href); + element.attr("src", proxy); + } + } + + return doc.body().html(); + } + + public static String proxyImage(String url) { + if (StringUtils.isBlank(url)) { + return url; + } + return "rest/server/proxy?u=" + imageProxyEncoder(url); + } + + public static String rot13(String msg) { + StringBuilder message = new StringBuilder(); + + for (char c : msg.toCharArray()) { + if (c >= 'a' && c <= 'm') { + c += 13; + } else if (c >= 'n' && c <= 'z') { + c -= 13; + } else if (c >= 'A' && c <= 'M') { + c += 13; + } else if (c >= 'N' && c <= 'Z') { + c -= 13; + } + message.append(c); + } + + return message.toString(); + } + + public static String imageProxyEncoder(String url) { + return Base64.encodeBase64String(rot13(url).getBytes()); + } + + public static String imageProxyDecoder(String code) { + return rot13(new String(Base64.decodeBase64(code))); + } + + public static void removeUnwantedFromSearch(List entries, List keywords) { + Iterator it = entries.iterator(); + while (it.hasNext()) { + Entry entry = it.next(); + boolean keep = true; + for (FeedEntryKeyword keyword : keywords) { + String title = entry.getTitle() == null ? null : Jsoup.parse(entry.getTitle()).text(); + String content = entry.getContent() == null ? null : Jsoup.parse(entry.getContent()).text(); + boolean condition = !StringUtils.containsIgnoreCase(content, keyword.getKeyword()) + && !StringUtils.containsIgnoreCase(title, keyword.getKeyword()); + if (keyword.getMode() == Mode.EXCLUDE) { + condition = !condition; + } + if (condition) { + keep = false; + break; + } + } + if (!keep) { + it.remove(); + } + } + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/EncodingDetector.java b/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/EncodingDetector.java index 133a7635..91b91dd9 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/EncodingDetector.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/EncodingDetector.java @@ -1,70 +1,70 @@ -package com.commafeed.backend.feed.parser; - -import java.nio.charset.Charset; - -import jakarta.inject.Singleton; - -import org.apache.commons.lang3.ArrayUtils; -import org.apache.commons.lang3.StringUtils; - -import com.ibm.icu.text.CharsetDetector; -import com.ibm.icu.text.CharsetMatch; - -@Singleton -class EncodingDetector { - - /** - * Detect feed encoding by using the declared encoding in the xml processing instruction and by detecting the characters used in the - * feed - * - */ - public Charset getEncoding(byte[] bytes) { - String extracted = extractDeclaredEncoding(bytes); - if (StringUtils.startsWithIgnoreCase(extracted, "iso-8859-")) { - if (!StringUtils.endsWith(extracted, "1")) { - return Charset.forName(extracted); - } - } else if (StringUtils.startsWithIgnoreCase(extracted, "windows-")) { - return Charset.forName(extracted); - } - return detectEncoding(bytes); - } - - /** - * Extract the declared encoding from the xml - */ - public String extractDeclaredEncoding(byte[] bytes) { - int index = ArrayUtils.indexOf(bytes, (byte) '>'); - if (index == -1) { - return null; - } - - String pi = new String(ArrayUtils.subarray(bytes, 0, index + 1)).replace('\'', '"'); - index = StringUtils.indexOf(pi, "encoding=\""); - if (index == -1) { - return null; - } - String encoding = pi.substring(index + 10); - encoding = encoding.substring(0, encoding.indexOf('"')); - return encoding; - } - - /** - * Detect encoding by analyzing characters in the array - */ - private Charset detectEncoding(byte[] bytes) { - String encoding = "UTF-8"; - - CharsetDetector detector = new CharsetDetector(); - detector.setText(bytes); - CharsetMatch match = detector.detect(); - if (match != null) { - encoding = match.getName(); - } - if (encoding.equalsIgnoreCase("ISO-8859-1")) { - encoding = "windows-1252"; - } - return Charset.forName(encoding); - } - -} +package com.commafeed.backend.feed.parser; + +import java.nio.charset.Charset; + +import jakarta.inject.Singleton; + +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.StringUtils; + +import com.ibm.icu.text.CharsetDetector; +import com.ibm.icu.text.CharsetMatch; + +@Singleton +class EncodingDetector { + + /** + * Detect feed encoding by using the declared encoding in the xml processing instruction and by detecting the characters used in the + * feed + * + */ + public Charset getEncoding(byte[] bytes) { + String extracted = extractDeclaredEncoding(bytes); + if (StringUtils.startsWithIgnoreCase(extracted, "iso-8859-")) { + if (!StringUtils.endsWith(extracted, "1")) { + return Charset.forName(extracted); + } + } else if (StringUtils.startsWithIgnoreCase(extracted, "windows-")) { + return Charset.forName(extracted); + } + return detectEncoding(bytes); + } + + /** + * Extract the declared encoding from the xml + */ + public String extractDeclaredEncoding(byte[] bytes) { + int index = ArrayUtils.indexOf(bytes, (byte) '>'); + if (index == -1) { + return null; + } + + String pi = new String(ArrayUtils.subarray(bytes, 0, index + 1)).replace('\'', '"'); + index = StringUtils.indexOf(pi, "encoding=\""); + if (index == -1) { + return null; + } + String encoding = pi.substring(index + 10); + encoding = encoding.substring(0, encoding.indexOf('"')); + return encoding; + } + + /** + * Detect encoding by analyzing characters in the array + */ + private Charset detectEncoding(byte[] bytes) { + String encoding = "UTF-8"; + + CharsetDetector detector = new CharsetDetector(); + detector.setText(bytes); + CharsetMatch match = detector.detect(); + if (match != null) { + encoding = match.getName(); + } + if (encoding.equalsIgnoreCase("ISO-8859-1")) { + encoding = "windows-1252"; + } + return Charset.forName(encoding); + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/FeedCleaner.java b/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/FeedCleaner.java index 167f275c..3ee52073 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/FeedCleaner.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/FeedCleaner.java @@ -1,70 +1,70 @@ -package com.commafeed.backend.feed.parser; - -import java.util.Collection; -import java.util.regex.Pattern; - -import jakarta.inject.Singleton; - -import org.ahocorasick.trie.Emit; -import org.ahocorasick.trie.Trie; -import org.apache.commons.lang3.StringUtils; - -@Singleton -class FeedCleaner { - - private static final Pattern DOCTYPE_PATTERN = Pattern.compile("]*>", Pattern.CASE_INSENSITIVE); - - public String trimInvalidXmlCharacters(String xml) { - if (StringUtils.isBlank(xml)) { - return null; - } - StringBuilder sb = new StringBuilder(); - - boolean firstTagFound = false; - for (int i = 0; i < xml.length(); i++) { - char c = xml.charAt(i); - - if (!firstTagFound) { - if (c == '<') { - firstTagFound = true; - } else { - continue; - } - } - - if (c >= 32 || c == 9 || c == 10 || c == 13) { - if (!Character.isHighSurrogate(c) && !Character.isLowSurrogate(c)) { - sb.append(c); - } - } - } - return sb.toString(); - } - - // https://stackoverflow.com/a/40836618 - public String replaceHtmlEntitiesWithNumericEntities(String source) { - // Create a buffer sufficiently large that re-allocations are minimized. - StringBuilder sb = new StringBuilder(source.length() << 1); - - Collection emits = Trie.builder().ignoreOverlaps().addKeywords(HtmlEntities.HTML_ENTITIES).build().parseText(source); - - int prevIndex = 0; - for (Emit emit : emits) { - int matchIndex = emit.getStart(); - - sb.append(source, prevIndex, matchIndex); - sb.append(HtmlEntities.HTML_TO_NUMERIC_MAP.get(emit.getKeyword())); - prevIndex = emit.getEnd() + 1; - } - - // Add the remainder of the string (contains no more matches). - sb.append(source.substring(prevIndex)); - - return sb.toString(); - } - - public String removeDoctypeDeclarations(String xml) { - return DOCTYPE_PATTERN.matcher(xml).replaceAll(""); - } - -} +package com.commafeed.backend.feed.parser; + +import java.util.Collection; +import java.util.regex.Pattern; + +import jakarta.inject.Singleton; + +import org.ahocorasick.trie.Emit; +import org.ahocorasick.trie.Trie; +import org.apache.commons.lang3.StringUtils; + +@Singleton +class FeedCleaner { + + private static final Pattern DOCTYPE_PATTERN = Pattern.compile("]*>", Pattern.CASE_INSENSITIVE); + + public String trimInvalidXmlCharacters(String xml) { + if (StringUtils.isBlank(xml)) { + return null; + } + StringBuilder sb = new StringBuilder(); + + boolean firstTagFound = false; + for (int i = 0; i < xml.length(); i++) { + char c = xml.charAt(i); + + if (!firstTagFound) { + if (c == '<') { + firstTagFound = true; + } else { + continue; + } + } + + if (c >= 32 || c == 9 || c == 10 || c == 13) { + if (!Character.isHighSurrogate(c) && !Character.isLowSurrogate(c)) { + sb.append(c); + } + } + } + return sb.toString(); + } + + // https://stackoverflow.com/a/40836618 + public String replaceHtmlEntitiesWithNumericEntities(String source) { + // Create a buffer sufficiently large that re-allocations are minimized. + StringBuilder sb = new StringBuilder(source.length() << 1); + + Collection emits = Trie.builder().ignoreOverlaps().addKeywords(HtmlEntities.HTML_ENTITIES).build().parseText(source); + + int prevIndex = 0; + for (Emit emit : emits) { + int matchIndex = emit.getStart(); + + sb.append(source, prevIndex, matchIndex); + sb.append(HtmlEntities.HTML_TO_NUMERIC_MAP.get(emit.getKeyword())); + prevIndex = emit.getEnd() + 1; + } + + // Add the remainder of the string (contains no more matches). + sb.append(source.substring(prevIndex)); + + return sb.toString(); + } + + public String removeDoctypeDeclarations(String xml) { + return DOCTYPE_PATTERN.matcher(xml).replaceAll(""); + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/FeedParser.java b/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/FeedParser.java index 8359236d..0ce0b41c 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/FeedParser.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/FeedParser.java @@ -1,285 +1,285 @@ -package com.commafeed.backend.feed.parser; - -import java.io.StringReader; -import java.nio.charset.Charset; -import java.text.DateFormat; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.Date; -import java.util.List; -import java.util.stream.Collectors; - -import jakarta.inject.Singleton; - -import org.apache.commons.lang3.ArrayUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.math3.stat.descriptive.SummaryStatistics; -import org.jdom2.Element; -import org.jdom2.Namespace; -import org.xml.sax.InputSource; - -import com.commafeed.backend.feed.FeedUtils; -import com.commafeed.backend.feed.parser.FeedParserResult.Content; -import com.commafeed.backend.feed.parser.FeedParserResult.Enclosure; -import com.commafeed.backend.feed.parser.FeedParserResult.Entry; -import com.commafeed.backend.feed.parser.FeedParserResult.Media; -import com.google.common.collect.Iterables; -import com.rometools.modules.mediarss.MediaEntryModule; -import com.rometools.modules.mediarss.MediaModule; -import com.rometools.modules.mediarss.types.MediaGroup; -import com.rometools.modules.mediarss.types.Metadata; -import com.rometools.modules.mediarss.types.Thumbnail; -import com.rometools.rome.feed.synd.SyndCategory; -import com.rometools.rome.feed.synd.SyndContent; -import com.rometools.rome.feed.synd.SyndEnclosure; -import com.rometools.rome.feed.synd.SyndEntry; -import com.rometools.rome.feed.synd.SyndFeed; -import com.rometools.rome.feed.synd.SyndLink; -import com.rometools.rome.feed.synd.SyndLinkImpl; -import com.rometools.rome.io.SyndFeedInput; - -import lombok.RequiredArgsConstructor; - -/** - * Parses raw xml into a FeedParserResult object - */ -@RequiredArgsConstructor -@Singleton -public class FeedParser { - - 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 END = Instant.ofEpochMilli(1000L * Integer.MAX_VALUE - 86400000); - - private final EncodingDetector encodingDetector; - private final FeedCleaner feedCleaner; - - public FeedParserResult parse(String feedUrl, byte[] xml) throws FeedParsingException { - try { - Charset encoding = encodingDetector.getEncoding(xml); - String xmlString = feedCleaner.trimInvalidXmlCharacters(new String(xml, encoding)); - if (xmlString == null) { - throw new FeedParsingException("Input string is null for url " + feedUrl); - } - xmlString = feedCleaner.replaceHtmlEntitiesWithNumericEntities(xmlString); - xmlString = feedCleaner.removeDoctypeDeclarations(xmlString); - - InputSource source = new InputSource(new StringReader(xmlString)); - SyndFeed feed = new SyndFeedInput().build(source); - handleForeignMarkup(feed); - - String title = feed.getTitle(); - String link = feed.getLink(); - List entries = buildEntries(feed, feedUrl); - Instant lastEntryDate = entries.stream().findFirst().map(Entry::published).orElse(null); - Instant lastPublishedDate = toValidInstant(feed.getPublishedDate(), false); - if (lastPublishedDate == null || lastEntryDate != null && lastPublishedDate.isBefore(lastEntryDate)) { - lastPublishedDate = lastEntryDate; - } - Long averageEntryInterval = averageTimeBetweenEntries(entries); - - return new FeedParserResult(title, link, lastPublishedDate, averageEntryInterval, lastEntryDate, entries); - } catch (FeedParsingException e) { - throw e; - } catch (Exception e) { - throw new FeedParsingException(String.format("Could not parse feed from %s : %s", feedUrl, e.getMessage()), e); - } - } - - /** - * Adds atom links for rss feeds - */ - private void handleForeignMarkup(SyndFeed feed) { - List foreignMarkup = feed.getForeignMarkup(); - if (foreignMarkup == null) { - return; - } - for (Element element : foreignMarkup) { - if ("link".equals(element.getName()) && ATOM_10_NS.equals(element.getNamespace())) { - SyndLink link = new SyndLinkImpl(); - link.setRel(element.getAttributeValue("rel")); - link.setHref(element.getAttributeValue("href")); - feed.getLinks().add(link); - } - } - } - - private List buildEntries(SyndFeed feed, String feedUrl) { - List entries = new ArrayList<>(); - - for (SyndEntry item : feed.getEntries()) { - String guid = item.getUri(); - if (StringUtils.isBlank(guid)) { - guid = item.getLink(); - } - if (StringUtils.isBlank(guid)) { - // no guid and no link, skip entry - continue; - } - - String url = buildEntryUrl(feed, feedUrl, item); - if (StringUtils.isBlank(url) && FeedUtils.isAbsoluteUrl(guid)) { - // if link is empty but guid is used as url, use guid - url = guid; - } - - Instant publishedDate = buildEntryPublishedDate(item); - Content content = buildContent(item); - - entries.add(new Entry(guid, url, publishedDate, content)); - } - - entries.sort(Comparator.comparing(Entry::published).reversed()); - return entries; - } - - private Content buildContent(SyndEntry item) { - String title = getTitle(item); - String content = getContent(item); - String author = StringUtils.trimToNull(item.getAuthor()); - String categories = StringUtils - .trimToNull(item.getCategories().stream().map(SyndCategory::getName).collect(Collectors.joining(", "))); - - Enclosure enclosure = buildEnclosure(item); - Media media = buildMedia(item); - return new Content(title, content, author, categories, enclosure, media); - } - - private Enclosure buildEnclosure(SyndEntry item) { - SyndEnclosure enclosure = Iterables.getFirst(item.getEnclosures(), null); - if (enclosure == null) { - return null; - } - - return new Enclosure(enclosure.getUrl(), enclosure.getType()); - } - - private Instant buildEntryPublishedDate(SyndEntry item) { - Date date = item.getPublishedDate(); - if (date == null) { - date = item.getUpdatedDate(); - } - return toValidInstant(date, true); - } - - private String buildEntryUrl(SyndFeed feed, String feedUrl, SyndEntry item) { - String url = StringUtils.trimToNull(StringUtils.normalizeSpace(item.getLink())); - if (url == null || FeedUtils.isAbsoluteUrl(url)) { - // url is absolute, nothing to do - return url; - } - - // url is relative, trying to resolve it - String feedLink = StringUtils.trimToNull(StringUtils.normalizeSpace(feed.getLink())); - return FeedUtils.toAbsoluteUrl(url, feedLink, feedUrl); - } - - private Instant toValidInstant(Date date, boolean nullToNow) { - Instant now = Instant.now(); - if (date == null) { - return nullToNow ? now : null; - } - - Instant instant = date.toInstant(); - if (instant.isBefore(START) || instant.isAfter(END)) { - return now; - } - - if (instant.isAfter(now)) { - return now; - } - return instant; - } - - private String getContent(SyndEntry item) { - String content; - if (item.getContents().isEmpty()) { - content = item.getDescription() == null ? null : item.getDescription().getValue(); - } else { - content = item.getContents().stream().map(SyndContent::getValue).collect(Collectors.joining(System.lineSeparator())); - } - return StringUtils.trimToNull(content); - } - - private String getTitle(SyndEntry item) { - String title = item.getTitle(); - if (StringUtils.isBlank(title)) { - Date date = item.getPublishedDate(); - if (date != null) { - title = DateFormat.getInstance().format(date); - } else { - title = "(no title)"; - } - } - return StringUtils.trimToNull(title); - } - - private Media buildMedia(SyndEntry item) { - MediaEntryModule module = (MediaEntryModule) item.getModule(MediaModule.URI); - if (module == null) { - return null; - } - - Media media = buildMedia(module.getMetadata()); - if (media == null && ArrayUtils.isNotEmpty(module.getMediaGroups())) { - MediaGroup group = module.getMediaGroups()[0]; - media = buildMedia(group.getMetadata()); - } - - return media; - } - - private Media buildMedia(Metadata metadata) { - if (metadata == null) { - return null; - } - - String description = metadata.getDescription(); - - String thumbnailUrl = null; - Integer thumbnailWidth = null; - Integer thumbnailHeight = null; - if (ArrayUtils.isNotEmpty(metadata.getThumbnail())) { - Thumbnail thumbnail = metadata.getThumbnail()[0]; - thumbnailWidth = thumbnail.getWidth(); - thumbnailHeight = thumbnail.getHeight(); - if (thumbnail.getUrl() != null) { - thumbnailUrl = thumbnail.getUrl().toString(); - } - } - - if (description == null && thumbnailUrl == null) { - return null; - } - - return new Media(description, thumbnailUrl, thumbnailWidth, thumbnailHeight); - } - - private Long averageTimeBetweenEntries(List entries) { - if (entries.isEmpty() || entries.size() == 1) { - return null; - } - - SummaryStatistics stats = new SummaryStatistics(); - for (int i = 0; i < entries.size() - 1; i++) { - long diff = Math.abs(entries.get(i).published().toEpochMilli() - entries.get(i + 1).published().toEpochMilli()); - stats.addValue(diff); - } - return (long) stats.getMean(); - } - - public static class FeedParsingException extends Exception { - private static final long serialVersionUID = 1L; - - public FeedParsingException(String message) { - super(message); - } - - public FeedParsingException(String message, Throwable cause) { - super(message, cause); - } - } - -} +package com.commafeed.backend.feed.parser; + +import java.io.StringReader; +import java.nio.charset.Charset; +import java.text.DateFormat; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; + +import jakarta.inject.Singleton; + +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.math3.stat.descriptive.SummaryStatistics; +import org.jdom2.Element; +import org.jdom2.Namespace; +import org.xml.sax.InputSource; + +import com.commafeed.backend.feed.FeedUtils; +import com.commafeed.backend.feed.parser.FeedParserResult.Content; +import com.commafeed.backend.feed.parser.FeedParserResult.Enclosure; +import com.commafeed.backend.feed.parser.FeedParserResult.Entry; +import com.commafeed.backend.feed.parser.FeedParserResult.Media; +import com.google.common.collect.Iterables; +import com.rometools.modules.mediarss.MediaEntryModule; +import com.rometools.modules.mediarss.MediaModule; +import com.rometools.modules.mediarss.types.MediaGroup; +import com.rometools.modules.mediarss.types.Metadata; +import com.rometools.modules.mediarss.types.Thumbnail; +import com.rometools.rome.feed.synd.SyndCategory; +import com.rometools.rome.feed.synd.SyndContent; +import com.rometools.rome.feed.synd.SyndEnclosure; +import com.rometools.rome.feed.synd.SyndEntry; +import com.rometools.rome.feed.synd.SyndFeed; +import com.rometools.rome.feed.synd.SyndLink; +import com.rometools.rome.feed.synd.SyndLinkImpl; +import com.rometools.rome.io.SyndFeedInput; + +import lombok.RequiredArgsConstructor; + +/** + * Parses raw xml into a FeedParserResult object + */ +@RequiredArgsConstructor +@Singleton +public class FeedParser { + + 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 END = Instant.ofEpochMilli(1000L * Integer.MAX_VALUE - 86400000); + + private final EncodingDetector encodingDetector; + private final FeedCleaner feedCleaner; + + public FeedParserResult parse(String feedUrl, byte[] xml) throws FeedParsingException { + try { + Charset encoding = encodingDetector.getEncoding(xml); + String xmlString = feedCleaner.trimInvalidXmlCharacters(new String(xml, encoding)); + if (xmlString == null) { + throw new FeedParsingException("Input string is null for url " + feedUrl); + } + xmlString = feedCleaner.replaceHtmlEntitiesWithNumericEntities(xmlString); + xmlString = feedCleaner.removeDoctypeDeclarations(xmlString); + + InputSource source = new InputSource(new StringReader(xmlString)); + SyndFeed feed = new SyndFeedInput().build(source); + handleForeignMarkup(feed); + + String title = feed.getTitle(); + String link = feed.getLink(); + List entries = buildEntries(feed, feedUrl); + Instant lastEntryDate = entries.stream().findFirst().map(Entry::published).orElse(null); + Instant lastPublishedDate = toValidInstant(feed.getPublishedDate(), false); + if (lastPublishedDate == null || lastEntryDate != null && lastPublishedDate.isBefore(lastEntryDate)) { + lastPublishedDate = lastEntryDate; + } + Long averageEntryInterval = averageTimeBetweenEntries(entries); + + return new FeedParserResult(title, link, lastPublishedDate, averageEntryInterval, lastEntryDate, entries); + } catch (FeedParsingException e) { + throw e; + } catch (Exception e) { + throw new FeedParsingException(String.format("Could not parse feed from %s : %s", feedUrl, e.getMessage()), e); + } + } + + /** + * Adds atom links for rss feeds + */ + private void handleForeignMarkup(SyndFeed feed) { + List foreignMarkup = feed.getForeignMarkup(); + if (foreignMarkup == null) { + return; + } + for (Element element : foreignMarkup) { + if ("link".equals(element.getName()) && ATOM_10_NS.equals(element.getNamespace())) { + SyndLink link = new SyndLinkImpl(); + link.setRel(element.getAttributeValue("rel")); + link.setHref(element.getAttributeValue("href")); + feed.getLinks().add(link); + } + } + } + + private List buildEntries(SyndFeed feed, String feedUrl) { + List entries = new ArrayList<>(); + + for (SyndEntry item : feed.getEntries()) { + String guid = item.getUri(); + if (StringUtils.isBlank(guid)) { + guid = item.getLink(); + } + if (StringUtils.isBlank(guid)) { + // no guid and no link, skip entry + continue; + } + + String url = buildEntryUrl(feed, feedUrl, item); + if (StringUtils.isBlank(url) && FeedUtils.isAbsoluteUrl(guid)) { + // if link is empty but guid is used as url, use guid + url = guid; + } + + Instant publishedDate = buildEntryPublishedDate(item); + Content content = buildContent(item); + + entries.add(new Entry(guid, url, publishedDate, content)); + } + + entries.sort(Comparator.comparing(Entry::published).reversed()); + return entries; + } + + private Content buildContent(SyndEntry item) { + String title = getTitle(item); + String content = getContent(item); + String author = StringUtils.trimToNull(item.getAuthor()); + String categories = StringUtils + .trimToNull(item.getCategories().stream().map(SyndCategory::getName).collect(Collectors.joining(", "))); + + Enclosure enclosure = buildEnclosure(item); + Media media = buildMedia(item); + return new Content(title, content, author, categories, enclosure, media); + } + + private Enclosure buildEnclosure(SyndEntry item) { + SyndEnclosure enclosure = Iterables.getFirst(item.getEnclosures(), null); + if (enclosure == null) { + return null; + } + + return new Enclosure(enclosure.getUrl(), enclosure.getType()); + } + + private Instant buildEntryPublishedDate(SyndEntry item) { + Date date = item.getPublishedDate(); + if (date == null) { + date = item.getUpdatedDate(); + } + return toValidInstant(date, true); + } + + private String buildEntryUrl(SyndFeed feed, String feedUrl, SyndEntry item) { + String url = StringUtils.trimToNull(StringUtils.normalizeSpace(item.getLink())); + if (url == null || FeedUtils.isAbsoluteUrl(url)) { + // url is absolute, nothing to do + return url; + } + + // url is relative, trying to resolve it + String feedLink = StringUtils.trimToNull(StringUtils.normalizeSpace(feed.getLink())); + return FeedUtils.toAbsoluteUrl(url, feedLink, feedUrl); + } + + private Instant toValidInstant(Date date, boolean nullToNow) { + Instant now = Instant.now(); + if (date == null) { + return nullToNow ? now : null; + } + + Instant instant = date.toInstant(); + if (instant.isBefore(START) || instant.isAfter(END)) { + return now; + } + + if (instant.isAfter(now)) { + return now; + } + return instant; + } + + private String getContent(SyndEntry item) { + String content; + if (item.getContents().isEmpty()) { + content = item.getDescription() == null ? null : item.getDescription().getValue(); + } else { + content = item.getContents().stream().map(SyndContent::getValue).collect(Collectors.joining(System.lineSeparator())); + } + return StringUtils.trimToNull(content); + } + + private String getTitle(SyndEntry item) { + String title = item.getTitle(); + if (StringUtils.isBlank(title)) { + Date date = item.getPublishedDate(); + if (date != null) { + title = DateFormat.getInstance().format(date); + } else { + title = "(no title)"; + } + } + return StringUtils.trimToNull(title); + } + + private Media buildMedia(SyndEntry item) { + MediaEntryModule module = (MediaEntryModule) item.getModule(MediaModule.URI); + if (module == null) { + return null; + } + + Media media = buildMedia(module.getMetadata()); + if (media == null && ArrayUtils.isNotEmpty(module.getMediaGroups())) { + MediaGroup group = module.getMediaGroups()[0]; + media = buildMedia(group.getMetadata()); + } + + return media; + } + + private Media buildMedia(Metadata metadata) { + if (metadata == null) { + return null; + } + + String description = metadata.getDescription(); + + String thumbnailUrl = null; + Integer thumbnailWidth = null; + Integer thumbnailHeight = null; + if (ArrayUtils.isNotEmpty(metadata.getThumbnail())) { + Thumbnail thumbnail = metadata.getThumbnail()[0]; + thumbnailWidth = thumbnail.getWidth(); + thumbnailHeight = thumbnail.getHeight(); + if (thumbnail.getUrl() != null) { + thumbnailUrl = thumbnail.getUrl().toString(); + } + } + + if (description == null && thumbnailUrl == null) { + return null; + } + + return new Media(description, thumbnailUrl, thumbnailWidth, thumbnailHeight); + } + + private Long averageTimeBetweenEntries(List entries) { + if (entries.isEmpty() || entries.size() == 1) { + return null; + } + + SummaryStatistics stats = new SummaryStatistics(); + for (int i = 0; i < entries.size() - 1; i++) { + long diff = Math.abs(entries.get(i).published().toEpochMilli() - entries.get(i + 1).published().toEpochMilli()); + stats.addValue(diff); + } + return (long) stats.getMean(); + } + + public static class FeedParsingException extends Exception { + private static final long serialVersionUID = 1L; + + public FeedParsingException(String message) { + super(message); + } + + public FeedParsingException(String message, Throwable cause) { + super(message, cause); + } + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/FeedParserResult.java b/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/FeedParserResult.java index 8404ca28..6e4e3c29 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/FeedParserResult.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/FeedParserResult.java @@ -1,20 +1,20 @@ -package com.commafeed.backend.feed.parser; - -import java.time.Instant; -import java.util.List; - -public record FeedParserResult(String title, String link, Instant lastPublishedDate, Long averageEntryInterval, Instant lastEntryDate, - List entries) { - 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 Enclosure(String url, String type) { - } - - public record Media(String description, String thumbnailUrl, Integer thumbnailWidth, Integer thumbnailHeight) { - } - -} +package com.commafeed.backend.feed.parser; + +import java.time.Instant; +import java.util.List; + +public record FeedParserResult(String title, String link, Instant lastPublishedDate, Long averageEntryInterval, Instant lastEntryDate, + List entries) { + 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 Enclosure(String url, String type) { + } + + public record Media(String description, String thumbnailUrl, Integer thumbnailWidth, Integer thumbnailHeight) { + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/HtmlEntities.java b/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/HtmlEntities.java index d38cccea..a46c7637 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/HtmlEntities.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/HtmlEntities.java @@ -1,272 +1,272 @@ -package com.commafeed.backend.feed.parser; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; - -import lombok.experimental.UtilityClass; - -@UtilityClass -class HtmlEntities { - public static final Map HTML_TO_NUMERIC_MAP; - public static final String[] HTML_ENTITIES; - public static final String[] NUMERIC_ENTITIES; - - static { - Map map = new LinkedHashMap<>(); - map.put("Á", "Á"); - map.put("á", "á"); - map.put("Â", "Â"); - map.put("â", "â"); - map.put("´", "´"); - map.put("Æ", "Æ"); - map.put("æ", "æ"); - map.put("À", "À"); - map.put("à", "à"); - map.put("ℵ", "ℵ"); - map.put("Α", "Α"); - map.put("α", "α"); - map.put("&", "&"); - map.put("∧", "∧"); - map.put("∠", "∠"); - map.put("Å", "Å"); - map.put("å", "å"); - map.put("≈", "≈"); - map.put("Ã", "Ã"); - map.put("ã", "ã"); - map.put("Ä", "Ä"); - map.put("ä", "ä"); - map.put("„", "„"); - map.put("Β", "Β"); - map.put("β", "β"); - map.put("¦", "¦"); - map.put("•", "•"); - map.put("∩", "∩"); - map.put("Ç", "Ç"); - map.put("ç", "ç"); - map.put("¸", "¸"); - map.put("¢", "¢"); - map.put("Χ", "Χ"); - map.put("χ", "χ"); - map.put("ˆ", "ˆ"); - map.put("♣", "♣"); - map.put("≅", "≅"); - map.put("©", "©"); - map.put("↵", "↵"); - map.put("∪", "∪"); - map.put("¤", "¤"); - map.put("†", "†"); - map.put("‡", "‡"); - map.put("↓", "↓"); - map.put("⇓", "⇓"); - map.put("°", "°"); - map.put("Δ", "Δ"); - map.put("δ", "δ"); - map.put("♦", "♦"); - map.put("÷", "÷"); - map.put("É", "É"); - map.put("é", "é"); - map.put("Ê", "Ê"); - map.put("ê", "ê"); - map.put("È", "È"); - map.put("è", "è"); - map.put("∅", "∅"); - map.put(" ", " "); - map.put(" ", " "); - map.put("Ε", "Ε"); - map.put("ε", "ε"); - map.put("≡", "≡"); - map.put("Η", "Η"); - map.put("η", "η"); - map.put("Ð", "Ð"); - map.put("ð", "ð"); - map.put("Ë", "Ë"); - map.put("ë", "ë"); - map.put("€", "€"); - map.put("∃", "∃"); - map.put("ƒ", "ƒ"); - map.put("∀", "∀"); - map.put("½", "½"); - map.put("¼", "¼"); - map.put("¾", "¾"); - map.put("⁄", "⁄"); - map.put("Γ", "Γ"); - map.put("γ", "γ"); - map.put("≥", "≥"); - map.put("↔", "↔"); - map.put("⇔", "⇔"); - map.put("♥", "♥"); - map.put("…", "…"); - map.put("Í", "Í"); - map.put("í", "í"); - map.put("Î", "Î"); - map.put("î", "î"); - map.put("¡", "¡"); - map.put("Ì", "Ì"); - map.put("ì", "ì"); - map.put("ℑ", "ℑ"); - map.put("∞", "∞"); - map.put("∫", "∫"); - map.put("Ι", "Ι"); - map.put("ι", "ι"); - map.put("¿", "¿"); - map.put("∈", "∈"); - map.put("Ï", "Ï"); - map.put("ï", "ï"); - map.put("Κ", "Κ"); - map.put("κ", "κ"); - map.put("Λ", "Λ"); - map.put("λ", "λ"); - map.put("⟨", "〈"); - map.put("«", "«"); - map.put("←", "←"); - map.put("⇐", "⇐"); - map.put("⌈", "⌈"); - map.put("“", "“"); - map.put("≤", "≤"); - map.put("⌊", "⌊"); - map.put("∗", "∗"); - map.put("◊", "◊"); - map.put("‎", "‎"); - map.put("‹", "‹"); - map.put("‘", "‘"); - map.put("¯", "¯"); - map.put("—", "—"); - map.put("µ", "µ"); - map.put("·", "·"); - map.put("−", "−"); - map.put("Μ", "Μ"); - map.put("μ", "μ"); - map.put("∇", "∇"); - map.put(" ", " "); - map.put("–", "–"); - map.put("≠", "≠"); - map.put("∋", "∋"); - map.put("¬", "¬"); - map.put("∉", "∉"); - map.put("⊄", "⊄"); - map.put("Ñ", "Ñ"); - map.put("ñ", "ñ"); - map.put("Ν", "Ν"); - map.put("ν", "ν"); - map.put("Ó", "Ó"); - map.put("ó", "ó"); - map.put("Ô", "Ô"); - map.put("ô", "ô"); - map.put("Œ", "Œ"); - map.put("œ", "œ"); - map.put("Ò", "Ò"); - map.put("ò", "ò"); - map.put("‾", "‾"); - map.put("Ω", "Ω"); - map.put("ω", "ω"); - map.put("Ο", "Ο"); - map.put("ο", "ο"); - map.put("⊕", "⊕"); - map.put("∨", "∨"); - map.put("ª", "ª"); - map.put("º", "º"); - map.put("Ø", "Ø"); - map.put("ø", "ø"); - map.put("Õ", "Õ"); - map.put("õ", "õ"); - map.put("⊗", "⊗"); - map.put("Ö", "Ö"); - map.put("ö", "ö"); - map.put("¶", "¶"); - map.put("∂", "∂"); - map.put("‰", "‰"); - map.put("⊥", "⊥"); - map.put("Φ", "Φ"); - map.put("φ", "φ"); - map.put("Π", "Π"); - map.put("π", "π"); - map.put("ϖ", "ϖ"); - map.put("±", "±"); - map.put("£", "£"); - map.put("′", "′"); - map.put("″", "″"); - map.put("∏", "∏"); - map.put("∝", "∝"); - map.put("Ψ", "Ψ"); - map.put("ψ", "ψ"); - map.put(""", """); - map.put("√", "√"); - map.put("⟩", "〉"); - map.put("»", "»"); - map.put("→", "→"); - map.put("⇒", "⇒"); - map.put("⌉", "⌉"); - map.put("”", "”"); - map.put("ℜ", "ℜ"); - map.put("®", "®"); - map.put("⌋", "⌋"); - map.put("Ρ", "Ρ"); - map.put("ρ", "ρ"); - map.put("‏", "‏"); - map.put("›", "›"); - map.put("’", "’"); - map.put("‚", "‚"); - map.put("Š", "Š"); - map.put("š", "š"); - map.put("⋅", "⋅"); - map.put("§", "§"); - map.put("­", "­"); - map.put("Σ", "Σ"); - map.put("σ", "σ"); - map.put("ς", "ς"); - map.put("∼", "∼"); - map.put("♠", "♠"); - map.put("⊂", "⊂"); - map.put("⊆", "⊆"); - map.put("∑", "∑"); - map.put("¹", "¹"); - map.put("²", "²"); - map.put("³", "³"); - map.put("⊃", "⊃"); - map.put("⊇", "⊇"); - map.put("ß", "ß"); - map.put("Τ", "Τ"); - map.put("τ", "τ"); - map.put("∴", "∴"); - map.put("Θ", "Θ"); - map.put("θ", "θ"); - map.put("ϑ", "ϑ"); - map.put(" ", " "); - map.put("Þ", "Þ"); - map.put("þ", "þ"); - map.put("˜", "˜"); - map.put("×", "×"); - map.put("™", "™"); - map.put("Ú", "Ú"); - map.put("ú", "ú"); - map.put("↑", "↑"); - map.put("⇑", "⇑"); - map.put("Û", "Û"); - map.put("û", "û"); - map.put("Ù", "Ù"); - map.put("ù", "ù"); - map.put("¨", "¨"); - map.put("ϒ", "ϒ"); - map.put("Υ", "Υ"); - map.put("υ", "υ"); - map.put("Ü", "Ü"); - map.put("ü", "ü"); - map.put("℘", "℘"); - map.put("Ξ", "Ξ"); - map.put("ξ", "ξ"); - map.put("Ý", "Ý"); - map.put("ý", "ý"); - map.put("¥", "¥"); - map.put("ÿ", "ÿ"); - map.put("Ÿ", "Ÿ"); - map.put("Ζ", "Ζ"); - map.put("ζ", "ζ"); - map.put("‍", "‍"); - map.put("‌", "‌"); - - HTML_TO_NUMERIC_MAP = Collections.unmodifiableMap(map); - HTML_ENTITIES = map.keySet().toArray(new String[0]); - NUMERIC_ENTITIES = map.values().toArray(new String[0]); - } -} +package com.commafeed.backend.feed.parser; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import lombok.experimental.UtilityClass; + +@UtilityClass +class HtmlEntities { + public static final Map HTML_TO_NUMERIC_MAP; + public static final String[] HTML_ENTITIES; + public static final String[] NUMERIC_ENTITIES; + + static { + Map map = new LinkedHashMap<>(); + map.put("Á", "Á"); + map.put("á", "á"); + map.put("Â", "Â"); + map.put("â", "â"); + map.put("´", "´"); + map.put("Æ", "Æ"); + map.put("æ", "æ"); + map.put("À", "À"); + map.put("à", "à"); + map.put("ℵ", "ℵ"); + map.put("Α", "Α"); + map.put("α", "α"); + map.put("&", "&"); + map.put("∧", "∧"); + map.put("∠", "∠"); + map.put("Å", "Å"); + map.put("å", "å"); + map.put("≈", "≈"); + map.put("Ã", "Ã"); + map.put("ã", "ã"); + map.put("Ä", "Ä"); + map.put("ä", "ä"); + map.put("„", "„"); + map.put("Β", "Β"); + map.put("β", "β"); + map.put("¦", "¦"); + map.put("•", "•"); + map.put("∩", "∩"); + map.put("Ç", "Ç"); + map.put("ç", "ç"); + map.put("¸", "¸"); + map.put("¢", "¢"); + map.put("Χ", "Χ"); + map.put("χ", "χ"); + map.put("ˆ", "ˆ"); + map.put("♣", "♣"); + map.put("≅", "≅"); + map.put("©", "©"); + map.put("↵", "↵"); + map.put("∪", "∪"); + map.put("¤", "¤"); + map.put("†", "†"); + map.put("‡", "‡"); + map.put("↓", "↓"); + map.put("⇓", "⇓"); + map.put("°", "°"); + map.put("Δ", "Δ"); + map.put("δ", "δ"); + map.put("♦", "♦"); + map.put("÷", "÷"); + map.put("É", "É"); + map.put("é", "é"); + map.put("Ê", "Ê"); + map.put("ê", "ê"); + map.put("È", "È"); + map.put("è", "è"); + map.put("∅", "∅"); + map.put(" ", " "); + map.put(" ", " "); + map.put("Ε", "Ε"); + map.put("ε", "ε"); + map.put("≡", "≡"); + map.put("Η", "Η"); + map.put("η", "η"); + map.put("Ð", "Ð"); + map.put("ð", "ð"); + map.put("Ë", "Ë"); + map.put("ë", "ë"); + map.put("€", "€"); + map.put("∃", "∃"); + map.put("ƒ", "ƒ"); + map.put("∀", "∀"); + map.put("½", "½"); + map.put("¼", "¼"); + map.put("¾", "¾"); + map.put("⁄", "⁄"); + map.put("Γ", "Γ"); + map.put("γ", "γ"); + map.put("≥", "≥"); + map.put("↔", "↔"); + map.put("⇔", "⇔"); + map.put("♥", "♥"); + map.put("…", "…"); + map.put("Í", "Í"); + map.put("í", "í"); + map.put("Î", "Î"); + map.put("î", "î"); + map.put("¡", "¡"); + map.put("Ì", "Ì"); + map.put("ì", "ì"); + map.put("ℑ", "ℑ"); + map.put("∞", "∞"); + map.put("∫", "∫"); + map.put("Ι", "Ι"); + map.put("ι", "ι"); + map.put("¿", "¿"); + map.put("∈", "∈"); + map.put("Ï", "Ï"); + map.put("ï", "ï"); + map.put("Κ", "Κ"); + map.put("κ", "κ"); + map.put("Λ", "Λ"); + map.put("λ", "λ"); + map.put("⟨", "〈"); + map.put("«", "«"); + map.put("←", "←"); + map.put("⇐", "⇐"); + map.put("⌈", "⌈"); + map.put("“", "“"); + map.put("≤", "≤"); + map.put("⌊", "⌊"); + map.put("∗", "∗"); + map.put("◊", "◊"); + map.put("‎", "‎"); + map.put("‹", "‹"); + map.put("‘", "‘"); + map.put("¯", "¯"); + map.put("—", "—"); + map.put("µ", "µ"); + map.put("·", "·"); + map.put("−", "−"); + map.put("Μ", "Μ"); + map.put("μ", "μ"); + map.put("∇", "∇"); + map.put(" ", " "); + map.put("–", "–"); + map.put("≠", "≠"); + map.put("∋", "∋"); + map.put("¬", "¬"); + map.put("∉", "∉"); + map.put("⊄", "⊄"); + map.put("Ñ", "Ñ"); + map.put("ñ", "ñ"); + map.put("Ν", "Ν"); + map.put("ν", "ν"); + map.put("Ó", "Ó"); + map.put("ó", "ó"); + map.put("Ô", "Ô"); + map.put("ô", "ô"); + map.put("Œ", "Œ"); + map.put("œ", "œ"); + map.put("Ò", "Ò"); + map.put("ò", "ò"); + map.put("‾", "‾"); + map.put("Ω", "Ω"); + map.put("ω", "ω"); + map.put("Ο", "Ο"); + map.put("ο", "ο"); + map.put("⊕", "⊕"); + map.put("∨", "∨"); + map.put("ª", "ª"); + map.put("º", "º"); + map.put("Ø", "Ø"); + map.put("ø", "ø"); + map.put("Õ", "Õ"); + map.put("õ", "õ"); + map.put("⊗", "⊗"); + map.put("Ö", "Ö"); + map.put("ö", "ö"); + map.put("¶", "¶"); + map.put("∂", "∂"); + map.put("‰", "‰"); + map.put("⊥", "⊥"); + map.put("Φ", "Φ"); + map.put("φ", "φ"); + map.put("Π", "Π"); + map.put("π", "π"); + map.put("ϖ", "ϖ"); + map.put("±", "±"); + map.put("£", "£"); + map.put("′", "′"); + map.put("″", "″"); + map.put("∏", "∏"); + map.put("∝", "∝"); + map.put("Ψ", "Ψ"); + map.put("ψ", "ψ"); + map.put(""", """); + map.put("√", "√"); + map.put("⟩", "〉"); + map.put("»", "»"); + map.put("→", "→"); + map.put("⇒", "⇒"); + map.put("⌉", "⌉"); + map.put("”", "”"); + map.put("ℜ", "ℜ"); + map.put("®", "®"); + map.put("⌋", "⌋"); + map.put("Ρ", "Ρ"); + map.put("ρ", "ρ"); + map.put("‏", "‏"); + map.put("›", "›"); + map.put("’", "’"); + map.put("‚", "‚"); + map.put("Š", "Š"); + map.put("š", "š"); + map.put("⋅", "⋅"); + map.put("§", "§"); + map.put("­", "­"); + map.put("Σ", "Σ"); + map.put("σ", "σ"); + map.put("ς", "ς"); + map.put("∼", "∼"); + map.put("♠", "♠"); + map.put("⊂", "⊂"); + map.put("⊆", "⊆"); + map.put("∑", "∑"); + map.put("¹", "¹"); + map.put("²", "²"); + map.put("³", "³"); + map.put("⊃", "⊃"); + map.put("⊇", "⊇"); + map.put("ß", "ß"); + map.put("Τ", "Τ"); + map.put("τ", "τ"); + map.put("∴", "∴"); + map.put("Θ", "Θ"); + map.put("θ", "θ"); + map.put("ϑ", "ϑ"); + map.put(" ", " "); + map.put("Þ", "Þ"); + map.put("þ", "þ"); + map.put("˜", "˜"); + map.put("×", "×"); + map.put("™", "™"); + map.put("Ú", "Ú"); + map.put("ú", "ú"); + map.put("↑", "↑"); + map.put("⇑", "⇑"); + map.put("Û", "Û"); + map.put("û", "û"); + map.put("Ù", "Ù"); + map.put("ù", "ù"); + map.put("¨", "¨"); + map.put("ϒ", "ϒ"); + map.put("Υ", "Υ"); + map.put("υ", "υ"); + map.put("Ü", "Ü"); + map.put("ü", "ü"); + map.put("℘", "℘"); + map.put("Ξ", "Ξ"); + map.put("ξ", "ξ"); + map.put("Ý", "Ý"); + map.put("ý", "ý"); + map.put("¥", "¥"); + map.put("ÿ", "ÿ"); + map.put("Ÿ", "Ÿ"); + map.put("Ζ", "Ζ"); + map.put("ζ", "ζ"); + map.put("‍", "‍"); + map.put("‌", "‌"); + + HTML_TO_NUMERIC_MAP = Collections.unmodifiableMap(map); + HTML_ENTITIES = map.keySet().toArray(new String[0]); + NUMERIC_ENTITIES = map.values().toArray(new String[0]); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/TextDirectionDetector.java b/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/TextDirectionDetector.java index 693dbda3..157b0048 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/TextDirectionDetector.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/feed/parser/TextDirectionDetector.java @@ -1,56 +1,56 @@ -package com.commafeed.backend.feed.parser; - -import java.text.Bidi; -import java.util.regex.Pattern; - -import org.apache.commons.lang3.math.NumberUtils; - -public class TextDirectionDetector { - - private static final Pattern WORDS_PATTERN = Pattern.compile("\\s+"); - private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*"); - - private static final double RTL_THRESHOLD = 0.4D; - - public enum Direction { - LEFT_TO_RIGHT, RIGHT_TO_LEFT - } - - public static Direction detect(String input) { - if (input == null || input.isBlank()) { - return Direction.LEFT_TO_RIGHT; - } - - long rtl = 0; - long total = 0; - for (String token : WORDS_PATTERN.split(input)) { - // skip urls - if (URL_PATTERN.matcher(token).matches()) { - continue; - } - - // skip numbers - if (NumberUtils.isCreatable(token)) { - continue; - } - - boolean requiresBidi = Bidi.requiresBidi(token.toCharArray(), 0, token.length()); - if (requiresBidi) { - Bidi bidi = new Bidi(token, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT); - if (bidi.getBaseLevel() == 1) { - rtl++; - } - } - - total++; - } - - if (total == 0) { - return Direction.LEFT_TO_RIGHT; - } - - double ratio = (double) rtl / total; - return ratio > RTL_THRESHOLD ? Direction.RIGHT_TO_LEFT : Direction.LEFT_TO_RIGHT; - } - -} +package com.commafeed.backend.feed.parser; + +import java.text.Bidi; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.math.NumberUtils; + +public class TextDirectionDetector { + + private static final Pattern WORDS_PATTERN = Pattern.compile("\\s+"); + private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*"); + + private static final double RTL_THRESHOLD = 0.4D; + + public enum Direction { + LEFT_TO_RIGHT, RIGHT_TO_LEFT + } + + public static Direction detect(String input) { + if (input == null || input.isBlank()) { + return Direction.LEFT_TO_RIGHT; + } + + long rtl = 0; + long total = 0; + for (String token : WORDS_PATTERN.split(input)) { + // skip urls + if (URL_PATTERN.matcher(token).matches()) { + continue; + } + + // skip numbers + if (NumberUtils.isCreatable(token)) { + continue; + } + + boolean requiresBidi = Bidi.requiresBidi(token.toCharArray(), 0, token.length()); + if (requiresBidi) { + Bidi bidi = new Bidi(token, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT); + if (bidi.getBaseLevel() == 1) { + rtl++; + } + } + + total++; + } + + if (total == 0) { + return Direction.LEFT_TO_RIGHT; + } + + double ratio = (double) rtl / total; + return ratio > RTL_THRESHOLD ? Direction.RIGHT_TO_LEFT : Direction.LEFT_TO_RIGHT; + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/model/AbstractModel.java b/commafeed-server/src/main/java/com/commafeed/backend/model/AbstractModel.java index 65198948..7ca6a9c0 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/model/AbstractModel.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/model/AbstractModel.java @@ -1,33 +1,33 @@ -package com.commafeed.backend.model; - -import java.io.Serializable; - -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.MappedSuperclass; -import jakarta.persistence.TableGenerator; - -import lombok.Getter; -import lombok.Setter; - -/** - * Abstract model for all entities, defining id and table generator - * - */ -@SuppressWarnings("serial") -@MappedSuperclass -@Getter -@Setter -public abstract class AbstractModel implements Serializable { - - @Id - @GeneratedValue(strategy = GenerationType.TABLE, generator = "gen") - @TableGenerator( - name = "gen", - table = "hibernate_sequences", - pkColumnName = "sequence_name", - valueColumnName = "sequence_next_hi_value", - allocationSize = 1000) - private Long id; -} +package com.commafeed.backend.model; + +import java.io.Serializable; + +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.TableGenerator; + +import lombok.Getter; +import lombok.Setter; + +/** + * Abstract model for all entities, defining id and table generator + * + */ +@SuppressWarnings("serial") +@MappedSuperclass +@Getter +@Setter +public abstract class AbstractModel implements Serializable { + + @Id + @GeneratedValue(strategy = GenerationType.TABLE, generator = "gen") + @TableGenerator( + name = "gen", + table = "hibernate_sequences", + pkColumnName = "sequence_name", + valueColumnName = "sequence_next_hi_value", + allocationSize = 1000) + private Long id; +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/model/Feed.java b/commafeed-server/src/main/java/com/commafeed/backend/model/Feed.java index 6b27bb0d..288cf0cb 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/model/Feed.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/model/Feed.java @@ -1,111 +1,111 @@ -package com.commafeed.backend.model; - -import java.sql.Types; -import java.time.Instant; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Lob; -import jakarta.persistence.Table; - -import org.hibernate.annotations.JdbcTypeCode; - -import lombok.Getter; -import lombok.Setter; - -@Entity -@Table(name = "FEEDS") -@SuppressWarnings("serial") -@Getter -@Setter -public class Feed extends AbstractModel { - - /** - * The url of the feed - */ - @Lob - @Column(length = Integer.MAX_VALUE, nullable = false) - @JdbcTypeCode(Types.LONGVARCHAR) - private String url; - - /** - * cache the url after potential http 30x redirects - */ - @Column(name = "url_after_redirect", length = 2048, nullable = false) - private String urlAfterRedirect; - - @Column(length = 2048, nullable = false) - private String normalizedUrl; - - @Column(length = 40, nullable = false) - private String normalizedUrlHash; - - /** - * The url of the website, extracted from the feed - */ - @Lob - @Column(length = Integer.MAX_VALUE) - @JdbcTypeCode(Types.LONGVARCHAR) - private String link; - - /** - * Last time we tried to fetch the feed - */ - @Column - private Instant lastUpdated; - - /** - * Last publishedDate value in the feed - */ - @Column - private Instant lastPublishedDate; - - /** - * date of the last entry of the feed - */ - @Column - private Instant lastEntryDate; - - /** - * error message while retrieving the feed - */ - @Lob - @Column(length = Integer.MAX_VALUE) - @JdbcTypeCode(Types.LONGVARCHAR) - private String message; - - /** - * times we failed to retrieve the feed - */ - private int errorCount; - - /** - * feed refresh is disabled until this date - */ - @Column - private Instant disabledUntil; - - /** - * http header returned by the feed - */ - @Column(length = 64) - private String lastModifiedHeader; - - /** - * http header returned by the feed - */ - @Column(length = 255) - private String etagHeader; - - /** - * average time between entries in the feed - */ - private Long averageEntryInterval; - - /** - * last hash of the content of the feed xml - */ - @Column(length = 40) - private String lastContentHash; - -} +package com.commafeed.backend.model; + +import java.sql.Types; +import java.time.Instant; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; + +import org.hibernate.annotations.JdbcTypeCode; + +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "FEEDS") +@SuppressWarnings("serial") +@Getter +@Setter +public class Feed extends AbstractModel { + + /** + * The url of the feed + */ + @Lob + @Column(length = Integer.MAX_VALUE, nullable = false) + @JdbcTypeCode(Types.LONGVARCHAR) + private String url; + + /** + * cache the url after potential http 30x redirects + */ + @Column(name = "url_after_redirect", length = 2048, nullable = false) + private String urlAfterRedirect; + + @Column(length = 2048, nullable = false) + private String normalizedUrl; + + @Column(length = 40, nullable = false) + private String normalizedUrlHash; + + /** + * The url of the website, extracted from the feed + */ + @Lob + @Column(length = Integer.MAX_VALUE) + @JdbcTypeCode(Types.LONGVARCHAR) + private String link; + + /** + * Last time we tried to fetch the feed + */ + @Column + private Instant lastUpdated; + + /** + * Last publishedDate value in the feed + */ + @Column + private Instant lastPublishedDate; + + /** + * date of the last entry of the feed + */ + @Column + private Instant lastEntryDate; + + /** + * error message while retrieving the feed + */ + @Lob + @Column(length = Integer.MAX_VALUE) + @JdbcTypeCode(Types.LONGVARCHAR) + private String message; + + /** + * times we failed to retrieve the feed + */ + private int errorCount; + + /** + * feed refresh is disabled until this date + */ + @Column + private Instant disabledUntil; + + /** + * http header returned by the feed + */ + @Column(length = 64) + private String lastModifiedHeader; + + /** + * http header returned by the feed + */ + @Column(length = 255) + private String etagHeader; + + /** + * average time between entries in the feed + */ + private Long averageEntryInterval; + + /** + * last hash of the content of the feed xml + */ + @Column(length = 40) + private String lastContentHash; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/model/FeedCategory.java b/commafeed-server/src/main/java/com/commafeed/backend/model/FeedCategory.java index ed61c962..a5c3ac03 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/model/FeedCategory.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/model/FeedCategory.java @@ -1,43 +1,43 @@ -package com.commafeed.backend.model; - -import java.util.Set; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; -import jakarta.persistence.Table; - -import lombok.Getter; -import lombok.Setter; - -@Entity -@Table(name = "FEEDCATEGORIES") -@SuppressWarnings("serial") -@Getter -@Setter -public class FeedCategory extends AbstractModel { - - @Column(length = 128, nullable = false) - private String name; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(nullable = false) - private User user; - - @ManyToOne(fetch = FetchType.LAZY) - private FeedCategory parent; - - @OneToMany(mappedBy = "parent") - private Set children; - - @OneToMany(mappedBy = "category") - private Set subscriptions; - - private boolean collapsed; - - private int position; - -} +package com.commafeed.backend.model; + +import java.util.Set; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "FEEDCATEGORIES") +@SuppressWarnings("serial") +@Getter +@Setter +public class FeedCategory extends AbstractModel { + + @Column(length = 128, nullable = false) + private String name; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + private FeedCategory parent; + + @OneToMany(mappedBy = "parent") + private Set children; + + @OneToMany(mappedBy = "category") + private Set subscriptions; + + private boolean collapsed; + + private int position; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/model/FeedEntry.java b/commafeed-server/src/main/java/com/commafeed/backend/model/FeedEntry.java index bcd33300..12ddc98e 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/model/FeedEntry.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/model/FeedEntry.java @@ -1,60 +1,60 @@ -package com.commafeed.backend.model; - -import java.time.Instant; -import java.util.Set; - -import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; -import jakarta.persistence.Table; - -import lombok.Getter; -import lombok.Setter; - -@Entity -@Table(name = "FEEDENTRIES") -@SuppressWarnings("serial") -@Getter -@Setter -public class FeedEntry extends AbstractModel { - - @Column(length = 2048, nullable = false) - private String guid; - - @Column(length = 40, nullable = false) - private String guidHash; - - @ManyToOne(fetch = FetchType.LAZY) - private Feed feed; - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(nullable = false, updatable = false) - private FeedEntryContent content; - - @Column(length = 2048) - private String url; - - /** - * the moment the entry was inserted in the database - */ - @Column - private Instant inserted; - - /** - * the moment the entry was published in the feed - * - */ - @Column(name = "updated") - private Instant published; - - @OneToMany(mappedBy = "entry", cascade = CascadeType.REMOVE) - private Set statuses; - - @OneToMany(mappedBy = "entry", cascade = CascadeType.REMOVE) - private Set tags; - -} +package com.commafeed.backend.model; + +import java.time.Instant; +import java.util.Set; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "FEEDENTRIES") +@SuppressWarnings("serial") +@Getter +@Setter +public class FeedEntry extends AbstractModel { + + @Column(length = 2048, nullable = false) + private String guid; + + @Column(length = 40, nullable = false) + private String guidHash; + + @ManyToOne(fetch = FetchType.LAZY) + private Feed feed; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(nullable = false, updatable = false) + private FeedEntryContent content; + + @Column(length = 2048) + private String url; + + /** + * the moment the entry was inserted in the database + */ + @Column + private Instant inserted; + + /** + * the moment the entry was published in the feed + * + */ + @Column(name = "updated") + private Instant published; + + @OneToMany(mappedBy = "entry", cascade = CascadeType.REMOVE) + private Set statuses; + + @OneToMany(mappedBy = "entry", cascade = CascadeType.REMOVE) + private Set tags; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/model/FeedEntryContent.java b/commafeed-server/src/main/java/com/commafeed/backend/model/FeedEntryContent.java index 3f91bf5b..b331ed8a 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/model/FeedEntryContent.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/model/FeedEntryContent.java @@ -1,105 +1,105 @@ -package com.commafeed.backend.model; - -import java.sql.Types; -import java.util.Set; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.Lob; -import jakarta.persistence.OneToMany; -import jakarta.persistence.Table; - -import org.apache.commons.lang3.builder.EqualsBuilder; -import org.hibernate.annotations.JdbcTypeCode; - -import com.commafeed.backend.feed.FeedUtils; - -import lombok.Getter; -import lombok.Setter; - -@Entity -@Table(name = "FEEDENTRYCONTENTS") -@SuppressWarnings("serial") -@Getter -@Setter -public class FeedEntryContent extends AbstractModel { - - public enum Direction { - ltr, rtl, unknown - } - - @Column(length = 2048) - private String title; - - @Column(length = 40) - private String titleHash; - - @Lob - @Column(length = Integer.MAX_VALUE) - @JdbcTypeCode(Types.LONGVARCHAR) - private String content; - - @Column(length = 40) - private String contentHash; - - @Column(name = "author", length = 128) - private String author; - - @Column(length = 2048) - private String enclosureUrl; - - @Column(length = 255) - private String enclosureType; - - @Lob - @Column(length = Integer.MAX_VALUE) - @JdbcTypeCode(Types.LONGVARCHAR) - private String mediaDescription; - - @Column(length = 2048) - private String mediaThumbnailUrl; - - private Integer mediaThumbnailWidth; - private Integer mediaThumbnailHeight; - - @Column(length = 4096) - private String categories; - - @Column - @Enumerated(EnumType.STRING) - private Direction direction = Direction.unknown; - - @OneToMany(mappedBy = "content") - private Set entries; - - public boolean equivalentTo(FeedEntryContent c) { - if (c == null) { - return false; - } - - return new EqualsBuilder().append(title, c.title) - .append(content, c.content) - .append(author, c.author) - .append(categories, c.categories) - .append(enclosureUrl, c.enclosureUrl) - .append(enclosureType, c.enclosureType) - .append(mediaDescription, c.mediaDescription) - .append(mediaThumbnailUrl, c.mediaThumbnailUrl) - .append(mediaThumbnailWidth, c.mediaThumbnailWidth) - .append(mediaThumbnailHeight, c.mediaThumbnailHeight) - .build(); - } - - public boolean isRTL() { - if (direction == Direction.rtl) { - return true; - } else if (direction == Direction.ltr) { - return false; - } else { - // detect on the fly for content that was inserted before the direction field was added - return FeedUtils.isRTL(title, content); - } - } -} +package com.commafeed.backend.model; + +import java.sql.Types; +import java.util.Set; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Lob; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import org.apache.commons.lang3.builder.EqualsBuilder; +import org.hibernate.annotations.JdbcTypeCode; + +import com.commafeed.backend.feed.FeedUtils; + +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "FEEDENTRYCONTENTS") +@SuppressWarnings("serial") +@Getter +@Setter +public class FeedEntryContent extends AbstractModel { + + public enum Direction { + ltr, rtl, unknown + } + + @Column(length = 2048) + private String title; + + @Column(length = 40) + private String titleHash; + + @Lob + @Column(length = Integer.MAX_VALUE) + @JdbcTypeCode(Types.LONGVARCHAR) + private String content; + + @Column(length = 40) + private String contentHash; + + @Column(name = "author", length = 128) + private String author; + + @Column(length = 2048) + private String enclosureUrl; + + @Column(length = 255) + private String enclosureType; + + @Lob + @Column(length = Integer.MAX_VALUE) + @JdbcTypeCode(Types.LONGVARCHAR) + private String mediaDescription; + + @Column(length = 2048) + private String mediaThumbnailUrl; + + private Integer mediaThumbnailWidth; + private Integer mediaThumbnailHeight; + + @Column(length = 4096) + private String categories; + + @Column + @Enumerated(EnumType.STRING) + private Direction direction = Direction.unknown; + + @OneToMany(mappedBy = "content") + private Set entries; + + public boolean equivalentTo(FeedEntryContent c) { + if (c == null) { + return false; + } + + return new EqualsBuilder().append(title, c.title) + .append(content, c.content) + .append(author, c.author) + .append(categories, c.categories) + .append(enclosureUrl, c.enclosureUrl) + .append(enclosureType, c.enclosureType) + .append(mediaDescription, c.mediaDescription) + .append(mediaThumbnailUrl, c.mediaThumbnailUrl) + .append(mediaThumbnailWidth, c.mediaThumbnailWidth) + .append(mediaThumbnailHeight, c.mediaThumbnailHeight) + .build(); + } + + public boolean isRTL() { + if (direction == Direction.rtl) { + return true; + } else if (direction == Direction.ltr) { + return false; + } else { + // detect on the fly for content that was inserted before the direction field was added + return FeedUtils.isRTL(title, content); + } + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/model/FeedEntryStatus.java b/commafeed-server/src/main/java/com/commafeed/backend/model/FeedEntryStatus.java index 0ccba2b3..98423e21 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/model/FeedEntryStatus.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/model/FeedEntryStatus.java @@ -1,69 +1,69 @@ -package com.commafeed.backend.model; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import jakarta.persistence.Transient; - -import lombok.Getter; -import lombok.Setter; - -@Entity -@Table(name = "FEEDENTRYSTATUSES") -@SuppressWarnings("serial") -@Getter -@Setter -public class FeedEntryStatus extends AbstractModel { - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(nullable = false) - private FeedSubscription subscription; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(nullable = false) - private FeedEntry entry; - - @Column(name = "read_status") - private boolean read; - private boolean starred; - - @Transient - private boolean markable; - - @Transient - private List tags = new ArrayList<>(); - - /** - * Denormalization starts here - */ - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(nullable = false) - private User user; - - @Column - private Instant entryInserted; - - @Column(name = "entryUpdated") - private Instant entryPublished; - - public FeedEntryStatus() { - - } - - public FeedEntryStatus(User user, FeedSubscription subscription, FeedEntry entry) { - this.user = user; - this.subscription = subscription; - this.entry = entry; - this.entryInserted = entry.getInserted(); - this.entryPublished = entry.getPublished(); - } - -} +package com.commafeed.backend.model; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; + +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "FEEDENTRYSTATUSES") +@SuppressWarnings("serial") +@Getter +@Setter +public class FeedEntryStatus extends AbstractModel { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(nullable = false) + private FeedSubscription subscription; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(nullable = false) + private FeedEntry entry; + + @Column(name = "read_status") + private boolean read; + private boolean starred; + + @Transient + private boolean markable; + + @Transient + private List tags = new ArrayList<>(); + + /** + * Denormalization starts here + */ + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(nullable = false) + private User user; + + @Column + private Instant entryInserted; + + @Column(name = "entryUpdated") + private Instant entryPublished; + + public FeedEntryStatus() { + + } + + public FeedEntryStatus(User user, FeedSubscription subscription, FeedEntry entry) { + this.user = user; + this.subscription = subscription; + this.entry = entry; + this.entryInserted = entry.getInserted(); + this.entryPublished = entry.getPublished(); + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/model/FeedEntryTag.java b/commafeed-server/src/main/java/com/commafeed/backend/model/FeedEntryTag.java index fb7983cb..8ca204a5 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/model/FeedEntryTag.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/model/FeedEntryTag.java @@ -1,40 +1,40 @@ -package com.commafeed.backend.model; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; - -import lombok.Getter; -import lombok.Setter; - -@Entity -@Table(name = "FEEDENTRYTAGS") -@SuppressWarnings("serial") -@Getter -@Setter -public class FeedEntryTag extends AbstractModel { - - @JoinColumn(name = "user_id") - @ManyToOne(fetch = FetchType.LAZY) - private User user; - - @JoinColumn(name = "entry_id") - @ManyToOne(fetch = FetchType.LAZY) - private FeedEntry entry; - - @Column(name = "name", length = 40) - private String name; - - public FeedEntryTag() { - } - - public FeedEntryTag(User user, FeedEntry entry, String name) { - this.name = name; - this.entry = entry; - this.user = user; - } - -} +package com.commafeed.backend.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "FEEDENTRYTAGS") +@SuppressWarnings("serial") +@Getter +@Setter +public class FeedEntryTag extends AbstractModel { + + @JoinColumn(name = "user_id") + @ManyToOne(fetch = FetchType.LAZY) + private User user; + + @JoinColumn(name = "entry_id") + @ManyToOne(fetch = FetchType.LAZY) + private FeedEntry entry; + + @Column(name = "name", length = 40) + private String name; + + public FeedEntryTag() { + } + + public FeedEntryTag(User user, FeedEntry entry, String name) { + this.name = name; + this.entry = entry; + this.user = user; + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/model/FeedSubscription.java b/commafeed-server/src/main/java/com/commafeed/backend/model/FeedSubscription.java index fafe0e0e..4c8c8028 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/model/FeedSubscription.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/model/FeedSubscription.java @@ -1,46 +1,46 @@ -package com.commafeed.backend.model; - -import java.util.Set; - -import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; -import jakarta.persistence.Table; - -import lombok.Getter; -import lombok.Setter; - -@Entity -@Table(name = "FEEDSUBSCRIPTIONS") -@SuppressWarnings("serial") -@Getter -@Setter -public class FeedSubscription extends AbstractModel { - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(nullable = false) - private User user; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(nullable = false) - private Feed feed; - - @Column(length = 128, nullable = false) - private String title; - - @ManyToOne(fetch = FetchType.LAZY) - private FeedCategory category; - - @OneToMany(mappedBy = "subscription", cascade = CascadeType.REMOVE) - private Set statuses; - - private int position; - - @Column(name = "filtering_expression", length = 4096) - private String filter; - -} +package com.commafeed.backend.model; + +import java.util.Set; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "FEEDSUBSCRIPTIONS") +@SuppressWarnings("serial") +@Getter +@Setter +public class FeedSubscription extends AbstractModel { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(nullable = false) + private Feed feed; + + @Column(length = 128, nullable = false) + private String title; + + @ManyToOne(fetch = FetchType.LAZY) + private FeedCategory category; + + @OneToMany(mappedBy = "subscription", cascade = CascadeType.REMOVE) + private Set statuses; + + private int position; + + @Column(name = "filtering_expression", length = 4096) + private String filter; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/model/Models.java b/commafeed-server/src/main/java/com/commafeed/backend/model/Models.java index bdd505d8..950c0a18 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/model/Models.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/model/Models.java @@ -1,41 +1,41 @@ -package com.commafeed.backend.model; - -import java.time.Duration; -import java.time.Instant; - -import org.hibernate.Hibernate; -import org.hibernate.HibernateException; -import org.hibernate.proxy.HibernateProxy; -import org.hibernate.proxy.LazyInitializer; - -import lombok.experimental.UtilityClass; - -@UtilityClass -public class Models { - - public static final Instant MINIMUM_INSTANT = Instant.EPOCH - // mariadb timestamp range starts at 1970-01-01 00:00:01 - .plusSeconds(1) - // make sure the timestamp fits for all timezones - .plus(Duration.ofHours(24)); - - /** - * initialize a proxy - */ - public static void initialize(Object proxy) throws HibernateException { - Hibernate.initialize(proxy); - } - - /** - * extract the id from the proxy without initializing it - */ - public static Long getId(AbstractModel model) { - if (model instanceof HibernateProxy proxy) { - LazyInitializer lazyInitializer = proxy.getHibernateLazyInitializer(); - if (lazyInitializer.isUninitialized()) { - return (Long) lazyInitializer.getIdentifier(); - } - } - return model.getId(); - } -} +package com.commafeed.backend.model; + +import java.time.Duration; +import java.time.Instant; + +import org.hibernate.Hibernate; +import org.hibernate.HibernateException; +import org.hibernate.proxy.HibernateProxy; +import org.hibernate.proxy.LazyInitializer; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public class Models { + + public static final Instant MINIMUM_INSTANT = Instant.EPOCH + // mariadb timestamp range starts at 1970-01-01 00:00:01 + .plusSeconds(1) + // make sure the timestamp fits for all timezones + .plus(Duration.ofHours(24)); + + /** + * initialize a proxy + */ + public static void initialize(Object proxy) throws HibernateException { + Hibernate.initialize(proxy); + } + + /** + * extract the id from the proxy without initializing it + */ + public static Long getId(AbstractModel model) { + if (model instanceof HibernateProxy proxy) { + LazyInitializer lazyInitializer = proxy.getHibernateLazyInitializer(); + if (lazyInitializer.isUninitialized()) { + return (Long) lazyInitializer.getIdentifier(); + } + } + return model.getId(); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/model/User.java b/commafeed-server/src/main/java/com/commafeed/backend/model/User.java index ce4ef976..59e39d9f 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/model/User.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/model/User.java @@ -1,59 +1,59 @@ -package com.commafeed.backend.model; - -import java.sql.Types; -import java.time.Instant; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Lob; -import jakarta.persistence.Table; - -import org.hibernate.annotations.JdbcTypeCode; - -import lombok.Getter; -import lombok.Setter; - -@Entity -@Table(name = "USERS") -@SuppressWarnings("serial") -@Getter -@Setter -public class User extends AbstractModel { - - @Column(length = 32, nullable = false, unique = true) - private String name; - - @Column(length = 255, unique = true) - private String email; - - @Lob - @Column(length = Integer.MAX_VALUE, nullable = false) - @JdbcTypeCode(Types.LONGVARBINARY) - private byte[] password; - - @Column(length = 40, unique = true) - private String apiKey; - - @Lob - @Column(length = Integer.MAX_VALUE, nullable = false) - @JdbcTypeCode(Types.LONGVARBINARY) - private byte[] salt; - - @Column(nullable = false) - private boolean disabled; - - @Column - private Instant lastLogin; - - @Column - private Instant created; - - @Column(length = 40) - private String recoverPasswordToken; - - @Column - private Instant recoverPasswordTokenDate; - - @Column - private Instant lastForceRefresh; -} +package com.commafeed.backend.model; + +import java.sql.Types; +import java.time.Instant; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; + +import org.hibernate.annotations.JdbcTypeCode; + +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "USERS") +@SuppressWarnings("serial") +@Getter +@Setter +public class User extends AbstractModel { + + @Column(length = 32, nullable = false, unique = true) + private String name; + + @Column(length = 255, unique = true) + private String email; + + @Lob + @Column(length = Integer.MAX_VALUE, nullable = false) + @JdbcTypeCode(Types.LONGVARBINARY) + private byte[] password; + + @Column(length = 40, unique = true) + private String apiKey; + + @Lob + @Column(length = Integer.MAX_VALUE, nullable = false) + @JdbcTypeCode(Types.LONGVARBINARY) + private byte[] salt; + + @Column(nullable = false) + private boolean disabled; + + @Column + private Instant lastLogin; + + @Column + private Instant created; + + @Column(length = 40) + private String recoverPasswordToken; + + @Column + private Instant recoverPasswordTokenDate; + + @Column + private Instant lastForceRefresh; +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/model/UserRole.java b/commafeed-server/src/main/java/com/commafeed/backend/model/UserRole.java index 89d34162..a5b46a3b 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/model/UserRole.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/model/UserRole.java @@ -1,43 +1,43 @@ -package com.commafeed.backend.model; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.OneToOne; -import jakarta.persistence.Table; - -import lombok.Getter; -import lombok.Setter; - -@Entity -@Table(name = "USERROLES") -@SuppressWarnings("serial") -@Getter -@Setter -public class UserRole extends AbstractModel { - - public enum Role { - USER, ADMIN - } - - @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private User user; - - @Column(name = "roleName", nullable = false) - @Enumerated(EnumType.STRING) - private Role role; - - public UserRole() { - - } - - public UserRole(User user, Role role) { - this.user = user; - this.role = role; - } - -} +package com.commafeed.backend.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "USERROLES") +@SuppressWarnings("serial") +@Getter +@Setter +public class UserRole extends AbstractModel { + + public enum Role { + USER, ADMIN + } + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "roleName", nullable = false) + @Enumerated(EnumType.STRING) + private Role role; + + public UserRole() { + + } + + public UserRole(User user, Role role) { + this.user = user; + this.role = role; + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/model/UserSettings.java b/commafeed-server/src/main/java/com/commafeed/backend/model/UserSettings.java index 4ba7af63..29bd8479 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/model/UserSettings.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/model/UserSettings.java @@ -1,107 +1,107 @@ -package com.commafeed.backend.model; - -import java.sql.Types; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.Lob; -import jakarta.persistence.OneToOne; -import jakarta.persistence.Table; - -import org.hibernate.annotations.JdbcTypeCode; - -import lombok.Getter; -import lombok.Setter; - -@Entity -@Table(name = "USERSETTINGS") -@SuppressWarnings("serial") -@Getter -@Setter -public class UserSettings extends AbstractModel { - - public enum ReadingMode { - all, unread - } - - public enum ReadingOrder { - asc, desc - } - - public enum ViewMode { - title, cozy, detailed, expanded - } - - public enum ScrollMode { - always, never, if_needed - } - - public enum IconDisplayMode { - always, never, on_desktop, on_mobile - } - - @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false, unique = true) - private User user; - - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private ReadingMode readingMode; - - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private ReadingOrder readingOrder; - - @Column(name = "user_lang", length = 4) - private String language; - - private boolean showRead; - private boolean scrollMarks; - - @Lob - @Column(length = Integer.MAX_VALUE) - @JdbcTypeCode(Types.LONGVARCHAR) - private String customCss; - - @Lob - @Column(length = Integer.MAX_VALUE) - @JdbcTypeCode(Types.LONGVARCHAR) - private String customJs; - - @Column(name = "scroll_speed") - private int scrollSpeed; - - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private ScrollMode scrollMode; - - private int entriesToKeepOnTopWhenScrolling; - - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private IconDisplayMode starIconDisplayMode; - - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private IconDisplayMode externalLinkIconDisplayMode; - - private boolean markAllAsReadConfirmation; - private boolean customContextMenu; - private boolean mobileFooter; - private boolean unreadCountTitle; - private boolean unreadCountFavicon; - - private boolean email; - private boolean gmail; - private boolean facebook; - private boolean twitter; - private boolean tumblr; - private boolean pocket; - private boolean instapaper; - private boolean buffer; - -} +package com.commafeed.backend.model; + +import java.sql.Types; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Lob; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; + +import org.hibernate.annotations.JdbcTypeCode; + +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "USERSETTINGS") +@SuppressWarnings("serial") +@Getter +@Setter +public class UserSettings extends AbstractModel { + + public enum ReadingMode { + all, unread + } + + public enum ReadingOrder { + asc, desc + } + + public enum ViewMode { + title, cozy, detailed, expanded + } + + public enum ScrollMode { + always, never, if_needed + } + + public enum IconDisplayMode { + always, never, on_desktop, on_mobile + } + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ReadingMode readingMode; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ReadingOrder readingOrder; + + @Column(name = "user_lang", length = 4) + private String language; + + private boolean showRead; + private boolean scrollMarks; + + @Lob + @Column(length = Integer.MAX_VALUE) + @JdbcTypeCode(Types.LONGVARCHAR) + private String customCss; + + @Lob + @Column(length = Integer.MAX_VALUE) + @JdbcTypeCode(Types.LONGVARCHAR) + private String customJs; + + @Column(name = "scroll_speed") + private int scrollSpeed; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ScrollMode scrollMode; + + private int entriesToKeepOnTopWhenScrolling; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private IconDisplayMode starIconDisplayMode; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private IconDisplayMode externalLinkIconDisplayMode; + + private boolean markAllAsReadConfirmation; + private boolean customContextMenu; + private boolean mobileFooter; + private boolean unreadCountTitle; + private boolean unreadCountFavicon; + + private boolean email; + private boolean gmail; + private boolean facebook; + private boolean twitter; + private boolean tumblr; + private boolean pocket; + private boolean instapaper; + private boolean buffer; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/opml/OPMLExporter.java b/commafeed-server/src/main/java/com/commafeed/backend/opml/OPMLExporter.java index f0d58d59..a47bd240 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/opml/OPMLExporter.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/opml/OPMLExporter.java @@ -1,85 +1,85 @@ -package com.commafeed.backend.opml; - -import java.util.Comparator; -import java.util.Date; -import java.util.List; - -import jakarta.inject.Singleton; - -import org.apache.commons.lang3.ObjectUtils; - -import com.commafeed.backend.dao.FeedCategoryDAO; -import com.commafeed.backend.dao.FeedSubscriptionDAO; -import com.commafeed.backend.model.FeedCategory; -import com.commafeed.backend.model.FeedSubscription; -import com.commafeed.backend.model.User; -import com.rometools.opml.feed.opml.Attribute; -import com.rometools.opml.feed.opml.Opml; -import com.rometools.opml.feed.opml.Outline; - -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Singleton -public class OPMLExporter { - - private final FeedCategoryDAO feedCategoryDAO; - private final FeedSubscriptionDAO feedSubscriptionDAO; - - public Opml export(User user) { - Opml opml = new Opml(); - opml.setFeedType("opml_1.0"); - opml.setTitle(String.format("%s subscriptions in CommaFeed", user.getName())); - opml.setCreated(new Date()); - - List categories = feedCategoryDAO.findAll(user); - categories.sort(Comparator.comparingInt(e -> ObjectUtils.firstNonNull(e.getPosition(), 0))); - - List subscriptions = feedSubscriptionDAO.findAll(user); - subscriptions.sort(Comparator.comparingInt(e -> ObjectUtils.firstNonNull(e.getPosition(), 0))); - - // export root categories - for (FeedCategory cat : categories.stream().filter(c -> c.getParent() == null).toList()) { - opml.getOutlines().add(buildCategoryOutline(cat, categories, subscriptions)); - } - - // export root subscriptions - for (FeedSubscription sub : subscriptions.stream().filter(s -> s.getCategory() == null).toList()) { - opml.getOutlines().add(buildSubscriptionOutline(sub)); - } - - return opml; - - } - - private Outline buildCategoryOutline(FeedCategory cat, List categories, List subscriptions) { - Outline outline = new Outline(); - outline.setText(cat.getName()); - outline.setTitle(cat.getName()); - - for (FeedCategory child : categories.stream() - .filter(c -> c.getParent() != null && c.getParent().getId().equals(cat.getId())) - .toList()) { - outline.getChildren().add(buildCategoryOutline(child, categories, subscriptions)); - } - - for (FeedSubscription sub : subscriptions.stream() - .filter(s -> s.getCategory() != null && s.getCategory().getId().equals(cat.getId())) - .toList()) { - outline.getChildren().add(buildSubscriptionOutline(sub)); - } - return outline; - } - - private Outline buildSubscriptionOutline(FeedSubscription sub) { - Outline outline = new Outline(); - outline.setText(sub.getTitle()); - outline.setTitle(sub.getTitle()); - outline.setType("rss"); - outline.getAttributes().add(new Attribute("xmlUrl", sub.getFeed().getUrl())); - if (sub.getFeed().getLink() != null) { - outline.getAttributes().add(new Attribute("htmlUrl", sub.getFeed().getLink())); - } - return outline; - } -} +package com.commafeed.backend.opml; + +import java.util.Comparator; +import java.util.Date; +import java.util.List; + +import jakarta.inject.Singleton; + +import org.apache.commons.lang3.ObjectUtils; + +import com.commafeed.backend.dao.FeedCategoryDAO; +import com.commafeed.backend.dao.FeedSubscriptionDAO; +import com.commafeed.backend.model.FeedCategory; +import com.commafeed.backend.model.FeedSubscription; +import com.commafeed.backend.model.User; +import com.rometools.opml.feed.opml.Attribute; +import com.rometools.opml.feed.opml.Opml; +import com.rometools.opml.feed.opml.Outline; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Singleton +public class OPMLExporter { + + private final FeedCategoryDAO feedCategoryDAO; + private final FeedSubscriptionDAO feedSubscriptionDAO; + + public Opml export(User user) { + Opml opml = new Opml(); + opml.setFeedType("opml_1.0"); + opml.setTitle(String.format("%s subscriptions in CommaFeed", user.getName())); + opml.setCreated(new Date()); + + List categories = feedCategoryDAO.findAll(user); + categories.sort(Comparator.comparingInt(e -> ObjectUtils.firstNonNull(e.getPosition(), 0))); + + List subscriptions = feedSubscriptionDAO.findAll(user); + subscriptions.sort(Comparator.comparingInt(e -> ObjectUtils.firstNonNull(e.getPosition(), 0))); + + // export root categories + for (FeedCategory cat : categories.stream().filter(c -> c.getParent() == null).toList()) { + opml.getOutlines().add(buildCategoryOutline(cat, categories, subscriptions)); + } + + // export root subscriptions + for (FeedSubscription sub : subscriptions.stream().filter(s -> s.getCategory() == null).toList()) { + opml.getOutlines().add(buildSubscriptionOutline(sub)); + } + + return opml; + + } + + private Outline buildCategoryOutline(FeedCategory cat, List categories, List subscriptions) { + Outline outline = new Outline(); + outline.setText(cat.getName()); + outline.setTitle(cat.getName()); + + for (FeedCategory child : categories.stream() + .filter(c -> c.getParent() != null && c.getParent().getId().equals(cat.getId())) + .toList()) { + outline.getChildren().add(buildCategoryOutline(child, categories, subscriptions)); + } + + for (FeedSubscription sub : subscriptions.stream() + .filter(s -> s.getCategory() != null && s.getCategory().getId().equals(cat.getId())) + .toList()) { + outline.getChildren().add(buildSubscriptionOutline(sub)); + } + return outline; + } + + private Outline buildSubscriptionOutline(FeedSubscription sub) { + Outline outline = new Outline(); + outline.setText(sub.getTitle()); + outline.setTitle(sub.getTitle()); + outline.setType("rss"); + outline.getAttributes().add(new Attribute("xmlUrl", sub.getFeed().getUrl())); + if (sub.getFeed().getLink() != null) { + outline.getAttributes().add(new Attribute("htmlUrl", sub.getFeed().getLink())); + } + return outline; + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/opml/OPMLImporter.java b/commafeed-server/src/main/java/com/commafeed/backend/opml/OPMLImporter.java index 7ddb116f..bae71b7a 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/opml/OPMLImporter.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/opml/OPMLImporter.java @@ -1,82 +1,82 @@ -package com.commafeed.backend.opml; - -import java.io.StringReader; -import java.util.List; - -import jakarta.inject.Singleton; - -import org.apache.commons.collections4.CollectionUtils; -import org.apache.commons.lang3.StringUtils; - -import com.commafeed.backend.dao.FeedCategoryDAO; -import com.commafeed.backend.feed.FeedUtils; -import com.commafeed.backend.model.FeedCategory; -import com.commafeed.backend.model.User; -import com.commafeed.backend.service.FeedSubscriptionService; -import com.rometools.opml.feed.opml.Opml; -import com.rometools.opml.feed.opml.Outline; -import com.rometools.rome.io.FeedException; -import com.rometools.rome.io.WireFeedInput; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@RequiredArgsConstructor -@Singleton -public class OPMLImporter { - - private final FeedCategoryDAO feedCategoryDAO; - private final FeedSubscriptionService feedSubscriptionService; - - public void importOpml(User user, String xml) throws IllegalArgumentException, FeedException { - xml = xml.substring(xml.indexOf('<')); - WireFeedInput input = new WireFeedInput(); - Opml feed = (Opml) input.build(new StringReader(xml)); - List outlines = feed.getOutlines(); - for (int i = 0; i < outlines.size(); i++) { - handleOutline(user, outlines.get(i), null, i); - } - } - - private void handleOutline(User user, Outline outline, FeedCategory parent, int position) { - List children = outline.getChildren(); - if (CollectionUtils.isNotEmpty(children)) { - String name = FeedUtils.truncate(outline.getText(), 128); - if (name == null) { - name = FeedUtils.truncate(outline.getTitle(), 128); - } - FeedCategory category = feedCategoryDAO.findByName(user, name, parent); - if (category == null) { - if (StringUtils.isBlank(name)) { - name = "Unnamed category"; - } - - category = new FeedCategory(); - category.setName(name); - category.setParent(parent); - category.setUser(user); - category.setPosition(position); - feedCategoryDAO.saveOrUpdate(category); - } - - for (int i = 0; i < children.size(); i++) { - handleOutline(user, children.get(i), category, i); - } - } else { - String name = FeedUtils.truncate(outline.getText(), 128); - if (name == null) { - name = FeedUtils.truncate(outline.getTitle(), 128); - } - if (StringUtils.isBlank(name)) { - name = "Unnamed subscription"; - } - // make sure we continue with the import process even if a feed failed - try { - feedSubscriptionService.subscribe(user, outline.getXmlUrl(), name, parent, position); - } catch (Exception e) { - log.error("error while importing {}: {}", outline.getXmlUrl(), e.getMessage()); - } - } - } -} +package com.commafeed.backend.opml; + +import java.io.StringReader; +import java.util.List; + +import jakarta.inject.Singleton; + +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import com.commafeed.backend.dao.FeedCategoryDAO; +import com.commafeed.backend.feed.FeedUtils; +import com.commafeed.backend.model.FeedCategory; +import com.commafeed.backend.model.User; +import com.commafeed.backend.service.FeedSubscriptionService; +import com.rometools.opml.feed.opml.Opml; +import com.rometools.opml.feed.opml.Outline; +import com.rometools.rome.io.FeedException; +import com.rometools.rome.io.WireFeedInput; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Singleton +public class OPMLImporter { + + private final FeedCategoryDAO feedCategoryDAO; + private final FeedSubscriptionService feedSubscriptionService; + + public void importOpml(User user, String xml) throws IllegalArgumentException, FeedException { + xml = xml.substring(xml.indexOf('<')); + WireFeedInput input = new WireFeedInput(); + Opml feed = (Opml) input.build(new StringReader(xml)); + List outlines = feed.getOutlines(); + for (int i = 0; i < outlines.size(); i++) { + handleOutline(user, outlines.get(i), null, i); + } + } + + private void handleOutline(User user, Outline outline, FeedCategory parent, int position) { + List children = outline.getChildren(); + if (CollectionUtils.isNotEmpty(children)) { + String name = FeedUtils.truncate(outline.getText(), 128); + if (name == null) { + name = FeedUtils.truncate(outline.getTitle(), 128); + } + FeedCategory category = feedCategoryDAO.findByName(user, name, parent); + if (category == null) { + if (StringUtils.isBlank(name)) { + name = "Unnamed category"; + } + + category = new FeedCategory(); + category.setName(name); + category.setParent(parent); + category.setUser(user); + category.setPosition(position); + feedCategoryDAO.saveOrUpdate(category); + } + + for (int i = 0; i < children.size(); i++) { + handleOutline(user, children.get(i), category, i); + } + } else { + String name = FeedUtils.truncate(outline.getText(), 128); + if (name == null) { + name = FeedUtils.truncate(outline.getTitle(), 128); + } + if (StringUtils.isBlank(name)) { + name = "Unnamed subscription"; + } + // make sure we continue with the import process even if a feed failed + try { + feedSubscriptionService.subscribe(user, outline.getXmlUrl(), name, parent, position); + } catch (Exception e) { + log.error("error while importing {}: {}", outline.getXmlUrl(), e.getMessage()); + } + } + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/rome/OPML11Generator.java b/commafeed-server/src/main/java/com/commafeed/backend/rome/OPML11Generator.java index 480a1ba7..1dde8a36 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/rome/OPML11Generator.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/rome/OPML11Generator.java @@ -1,26 +1,26 @@ -package com.commafeed.backend.rome; - -import org.jdom2.Element; - -import com.rometools.opml.feed.opml.Opml; - -import io.quarkus.runtime.annotations.RegisterForReflection; - -/** - * Add missing title to the generated OPML - * - */ -@RegisterForReflection -public class OPML11Generator extends com.rometools.opml.io.impl.OPML10Generator { - - public OPML11Generator() { - super("opml_1.1"); - } - - @Override - protected Element generateHead(Opml opml) { - Element head = new Element("head"); - addNotNullSimpleElement(head, "title", opml.getTitle()); - return head; - } -} +package com.commafeed.backend.rome; + +import org.jdom2.Element; + +import com.rometools.opml.feed.opml.Opml; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +/** + * Add missing title to the generated OPML + * + */ +@RegisterForReflection +public class OPML11Generator extends com.rometools.opml.io.impl.OPML10Generator { + + public OPML11Generator() { + super("opml_1.1"); + } + + @Override + protected Element generateHead(Opml opml) { + Element head = new Element("head"); + addNotNullSimpleElement(head, "title", opml.getTitle()); + return head; + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/rome/OPML11Parser.java b/commafeed-server/src/main/java/com/commafeed/backend/rome/OPML11Parser.java index 8770667a..254d1a05 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/rome/OPML11Parser.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/rome/OPML11Parser.java @@ -1,38 +1,38 @@ -package com.commafeed.backend.rome; - -import java.util.Locale; - -import org.jdom2.Document; -import org.jdom2.Element; - -import com.rometools.opml.io.impl.OPML10Parser; -import com.rometools.rome.feed.WireFeed; -import com.rometools.rome.io.FeedException; - -import io.quarkus.runtime.annotations.RegisterForReflection; - -/** - * Support for OPML 1.1 parsing - * - */ -@RegisterForReflection -public class OPML11Parser extends OPML10Parser { - - public OPML11Parser() { - super("opml_1.1"); - } - - @Override - public boolean isMyType(Document document) { - Element e = document.getRootElement(); - - return e.getName().equals("opml"); - - } - - @Override - public WireFeed parse(Document document, boolean validate, Locale locale) throws IllegalArgumentException, FeedException { - document.getRootElement().getChildren().add(new Element("head")); - return super.parse(document, validate, locale); - } -} +package com.commafeed.backend.rome; + +import java.util.Locale; + +import org.jdom2.Document; +import org.jdom2.Element; + +import com.rometools.opml.io.impl.OPML10Parser; +import com.rometools.rome.feed.WireFeed; +import com.rometools.rome.io.FeedException; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +/** + * Support for OPML 1.1 parsing + * + */ +@RegisterForReflection +public class OPML11Parser extends OPML10Parser { + + public OPML11Parser() { + super("opml_1.1"); + } + + @Override + public boolean isMyType(Document document) { + Element e = document.getRootElement(); + + return e.getName().equals("opml"); + + } + + @Override + public WireFeed parse(Document document, boolean validate, Locale locale) throws IllegalArgumentException, FeedException { + document.getRootElement().getChildren().add(new Element("head")); + return super.parse(document, validate, locale); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/rome/RSS090DescriptionConverter.java b/commafeed-server/src/main/java/com/commafeed/backend/rome/RSS090DescriptionConverter.java index 404364b2..a0b7bd9c 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/rome/RSS090DescriptionConverter.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/rome/RSS090DescriptionConverter.java @@ -1,30 +1,30 @@ -package com.commafeed.backend.rome; - -import com.rometools.rome.feed.rss.Description; -import com.rometools.rome.feed.rss.Item; -import com.rometools.rome.feed.synd.SyndContentImpl; -import com.rometools.rome.feed.synd.SyndEntry; -import com.rometools.rome.feed.synd.impl.ConverterForRSS090; - -import io.quarkus.runtime.annotations.RegisterForReflection; - -/** - * Support description tag for RSS09 - * - */ -@RegisterForReflection -public class RSS090DescriptionConverter extends ConverterForRSS090 { - - @Override - protected SyndEntry createSyndEntry(Item item, boolean preserveWireItem) { - SyndEntry entry = super.createSyndEntry(item, preserveWireItem); - Description desc = item.getDescription(); - if (desc != null) { - SyndContentImpl syndDesc = new SyndContentImpl(); - syndDesc.setValue(desc.getValue()); - entry.setDescription(syndDesc); - } - return entry; - } - -} +package com.commafeed.backend.rome; + +import com.rometools.rome.feed.rss.Description; +import com.rometools.rome.feed.rss.Item; +import com.rometools.rome.feed.synd.SyndContentImpl; +import com.rometools.rome.feed.synd.SyndEntry; +import com.rometools.rome.feed.synd.impl.ConverterForRSS090; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +/** + * Support description tag for RSS09 + * + */ +@RegisterForReflection +public class RSS090DescriptionConverter extends ConverterForRSS090 { + + @Override + protected SyndEntry createSyndEntry(Item item, boolean preserveWireItem) { + SyndEntry entry = super.createSyndEntry(item, preserveWireItem); + Description desc = item.getDescription(); + if (desc != null) { + SyndContentImpl syndDesc = new SyndContentImpl(); + syndDesc.setValue(desc.getValue()); + entry.setDescription(syndDesc); + } + return entry; + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/rome/RSS090DescriptionParser.java b/commafeed-server/src/main/java/com/commafeed/backend/rome/RSS090DescriptionParser.java index abde98ed..dedf5c2c 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/rome/RSS090DescriptionParser.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/rome/RSS090DescriptionParser.java @@ -1,32 +1,32 @@ -package com.commafeed.backend.rome; - -import java.util.Locale; - -import org.jdom2.Element; - -import com.rometools.rome.feed.rss.Description; -import com.rometools.rome.feed.rss.Item; -import com.rometools.rome.io.impl.RSS090Parser; - -import io.quarkus.runtime.annotations.RegisterForReflection; - -/** - * Support description tag for RSS09 - * - */ -@RegisterForReflection -public class RSS090DescriptionParser extends RSS090Parser { - - @Override - protected Item parseItem(Element rssRoot, Element eItem, Locale locale) { - Item item = super.parseItem(rssRoot, eItem, locale); - Element e = eItem.getChild("description", getRSSNamespace()); - if (e != null) { - Description desc = new Description(); - desc.setValue(e.getText()); - item.setDescription(desc); - } - - return item; - } -} +package com.commafeed.backend.rome; + +import java.util.Locale; + +import org.jdom2.Element; + +import com.rometools.rome.feed.rss.Description; +import com.rometools.rome.feed.rss.Item; +import com.rometools.rome.io.impl.RSS090Parser; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +/** + * Support description tag for RSS09 + * + */ +@RegisterForReflection +public class RSS090DescriptionParser extends RSS090Parser { + + @Override + protected Item parseItem(Element rssRoot, Element eItem, Locale locale) { + Item item = super.parseItem(rssRoot, eItem, locale); + Element e = eItem.getChild("description", getRSSNamespace()); + if (e != null) { + Description desc = new Description(); + desc.setValue(e.getText()); + item.setDescription(desc); + } + + return item; + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/rome/RSSRDF10Parser.java b/commafeed-server/src/main/java/com/commafeed/backend/rome/RSSRDF10Parser.java index 6c7e11c7..286cb8de 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/rome/RSSRDF10Parser.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/rome/RSSRDF10Parser.java @@ -1,48 +1,48 @@ -package com.commafeed.backend.rome; - -import java.util.List; - -import org.apache.commons.collections4.CollectionUtils; -import org.jdom2.Document; -import org.jdom2.Element; -import org.jdom2.Namespace; - -import com.google.common.collect.Lists; -import com.rometools.rome.io.impl.RSS10Parser; - -import io.quarkus.runtime.annotations.RegisterForReflection; - -@RegisterForReflection -public class RSSRDF10Parser extends RSS10Parser { - - private static final String RSS_URI = "http://purl.org/rss/1.0/"; - private static final Namespace RSS_NS = Namespace.getNamespace(RSS_URI); - - public RSSRDF10Parser() { - super("rss_1.0", RSS_NS); - } - - @Override - public boolean isMyType(Document document) { - boolean ok; - - Element rssRoot = document.getRootElement(); - Namespace defaultNS = rssRoot.getNamespace(); - List additionalNSs = Lists.newArrayList(rssRoot.getAdditionalNamespaces()); - List children = rssRoot.getChildren(); - if (CollectionUtils.isNotEmpty(children)) { - Element child = children.get(0); - additionalNSs.add(child.getNamespace()); - additionalNSs.addAll(child.getAdditionalNamespaces()); - } - - ok = defaultNS != null && defaultNS.equals(getRDFNamespace()); - if (ok) { - ok = false; - for (int i = 0; !ok && i < additionalNSs.size(); i++) { - ok = getRSSNamespace().equals(additionalNSs.get(i)); - } - } - return ok; - } -} +package com.commafeed.backend.rome; + +import java.util.List; + +import org.apache.commons.collections4.CollectionUtils; +import org.jdom2.Document; +import org.jdom2.Element; +import org.jdom2.Namespace; + +import com.google.common.collect.Lists; +import com.rometools.rome.io.impl.RSS10Parser; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public class RSSRDF10Parser extends RSS10Parser { + + private static final String RSS_URI = "http://purl.org/rss/1.0/"; + private static final Namespace RSS_NS = Namespace.getNamespace(RSS_URI); + + public RSSRDF10Parser() { + super("rss_1.0", RSS_NS); + } + + @Override + public boolean isMyType(Document document) { + boolean ok; + + Element rssRoot = document.getRootElement(); + Namespace defaultNS = rssRoot.getNamespace(); + List additionalNSs = Lists.newArrayList(rssRoot.getAdditionalNamespaces()); + List children = rssRoot.getChildren(); + if (CollectionUtils.isNotEmpty(children)) { + Element child = children.get(0); + additionalNSs.add(child.getNamespace()); + additionalNSs.addAll(child.getAdditionalNamespaces()); + } + + ok = defaultNS != null && defaultNS.equals(getRDFNamespace()); + if (ok) { + ok = false; + for (int i = 0; !ok && i < additionalNSs.size(); i++) { + ok = getRSSNamespace().equals(additionalNSs.get(i)); + } + } + return ok; + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/service/FeedEntryContentCleaningService.java b/commafeed-server/src/main/java/com/commafeed/backend/service/FeedEntryContentCleaningService.java index c9124309..80fc60c7 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/service/FeedEntryContentCleaningService.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/service/FeedEntryContentCleaningService.java @@ -1,175 +1,175 @@ -package com.commafeed.backend.service; - -import java.io.StringReader; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -import jakarta.inject.Singleton; - -import org.apache.commons.lang3.StringUtils; -import org.jsoup.Jsoup; -import org.jsoup.nodes.Document; -import org.jsoup.nodes.Document.OutputSettings; -import org.jsoup.nodes.Element; -import org.jsoup.nodes.Entities.EscapeMode; -import org.jsoup.safety.Cleaner; -import org.jsoup.safety.Safelist; -import org.w3c.css.sac.CSSException; -import org.w3c.css.sac.CSSParseException; -import org.w3c.css.sac.ErrorHandler; -import org.w3c.css.sac.InputSource; -import org.w3c.dom.css.CSSStyleDeclaration; - -import com.steadystate.css.parser.CSSOMParser; -import com.steadystate.css.parser.SACParserCSS21; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@RequiredArgsConstructor -@Slf4j -@Singleton -public class FeedEntryContentCleaningService { - - private static final Safelist HTML_WHITELIST = buildWhiteList(); - private static final List ALLOWED_IFRAME_CSS_RULES = Arrays.asList("height", "width", "border"); - private static final List ALLOWED_IMG_CSS_RULES = Arrays.asList("display", "width", "height"); - private static final char[] FORBIDDEN_CSS_RULE_CHARACTERS = new char[] { '(', ')' }; - - public String clean(String content, String baseUri, boolean keepTextOnly) { - if (StringUtils.isNotBlank(content)) { - baseUri = StringUtils.trimToEmpty(baseUri); - - Document dirty = Jsoup.parseBodyFragment(content, baseUri); - Cleaner cleaner = new Cleaner(HTML_WHITELIST); - Document clean = cleaner.clean(dirty); - - for (Element e : clean.select("iframe[style]")) { - String style = e.attr("style"); - String escaped = escapeIFrameCss(style); - e.attr("style", escaped); - } - - for (Element e : clean.select("img[style]")) { - String style = e.attr("style"); - String escaped = escapeImgCss(style); - e.attr("style", escaped); - } - - clean.outputSettings(new OutputSettings().escapeMode(EscapeMode.base).prettyPrint(false)); - Element body = clean.body(); - if (keepTextOnly) { - content = body.text(); - } else { - content = body.html(); - } - } - return content; - } - - private static Safelist buildWhiteList() { - Safelist whitelist = new Safelist(); - 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", - "table", "tbody", "td", "tfoot", "th", "thead", "tr", "u", "ul"); - - whitelist.addAttributes("div", "dir"); - whitelist.addAttributes("pre", "dir"); - whitelist.addAttributes("code", "dir"); - whitelist.addAttributes("table", "dir"); - whitelist.addAttributes("p", "dir"); - whitelist.addAttributes("a", "href", "title"); - whitelist.addAttributes("blockquote", "cite"); - whitelist.addAttributes("col", "span", "width"); - whitelist.addAttributes("colgroup", "span", "width"); - whitelist.addAttributes("iframe", "src", "height", "width", "allowfullscreen", "frameborder", "style"); - whitelist.addAttributes("img", "align", "alt", "height", "src", "title", "width", "style"); - whitelist.addAttributes("ol", "start", "type"); - whitelist.addAttributes("q", "cite"); - whitelist.addAttributes("table", "border", "bordercolor", "summary", "width"); - whitelist.addAttributes("td", "border", "bordercolor", "abbr", "axis", "colspan", "rowspan", "width"); - whitelist.addAttributes("th", "border", "bordercolor", "abbr", "axis", "colspan", "rowspan", "scope", "width"); - whitelist.addAttributes("ul", "type"); - - whitelist.addProtocols("a", "href", "ftp", "http", "https", "magnet", "mailto"); - whitelist.addProtocols("blockquote", "cite", "http", "https"); - whitelist.addProtocols("img", "src", "http", "https"); - whitelist.addProtocols("q", "cite", "http", "https"); - - whitelist.addEnforcedAttribute("a", "target", "_blank"); - whitelist.addEnforcedAttribute("a", "rel", "noreferrer"); - return whitelist; - } - - private String escapeIFrameCss(String orig) { - String rule = ""; - try { - List rules = new ArrayList<>(); - CSSStyleDeclaration decl = buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig))); - - for (int i = 0; i < decl.getLength(); i++) { - String property = decl.item(i); - String value = decl.getPropertyValue(property); - if (StringUtils.isBlank(property) || StringUtils.isBlank(value)) { - continue; - } - - if (ALLOWED_IFRAME_CSS_RULES.contains(property) && StringUtils.containsNone(value, FORBIDDEN_CSS_RULE_CHARACTERS)) { - rules.add(property + ":" + decl.getPropertyValue(property) + ";"); - } - } - rule = StringUtils.join(rules, ""); - } catch (Exception e) { - log.error(e.getMessage(), e); - } - return rule; - } - - private String escapeImgCss(String orig) { - String rule = ""; - try { - List rules = new ArrayList<>(); - CSSStyleDeclaration decl = buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig))); - - for (int i = 0; i < decl.getLength(); i++) { - String property = decl.item(i); - String value = decl.getPropertyValue(property); - if (StringUtils.isBlank(property) || StringUtils.isBlank(value)) { - continue; - } - - if (ALLOWED_IMG_CSS_RULES.contains(property) && StringUtils.containsNone(value, FORBIDDEN_CSS_RULE_CHARACTERS)) { - rules.add(property + ":" + decl.getPropertyValue(property) + ";"); - } - } - rule = StringUtils.join(rules, ""); - } catch (Exception e) { - log.error(e.getMessage(), e); - } - return rule; - } - - private CSSOMParser buildCssParser() { - CSSOMParser parser = new CSSOMParser(new SACParserCSS21()); - - parser.setErrorHandler(new ErrorHandler() { - @Override - public void warning(CSSParseException exception) throws CSSException { - log.debug("warning while parsing css: {}", exception.getMessage(), exception); - } - - @Override - public void error(CSSParseException exception) throws CSSException { - log.debug("error while parsing css: {}", exception.getMessage(), exception); - } - - @Override - public void fatalError(CSSParseException exception) throws CSSException { - log.debug("fatal error while parsing css: {}", exception.getMessage(), exception); - } - }); - - return parser; - } -} +package com.commafeed.backend.service; + +import java.io.StringReader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import jakarta.inject.Singleton; + +import org.apache.commons.lang3.StringUtils; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Document.OutputSettings; +import org.jsoup.nodes.Element; +import org.jsoup.nodes.Entities.EscapeMode; +import org.jsoup.safety.Cleaner; +import org.jsoup.safety.Safelist; +import org.w3c.css.sac.CSSException; +import org.w3c.css.sac.CSSParseException; +import org.w3c.css.sac.ErrorHandler; +import org.w3c.css.sac.InputSource; +import org.w3c.dom.css.CSSStyleDeclaration; + +import com.steadystate.css.parser.CSSOMParser; +import com.steadystate.css.parser.SACParserCSS21; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +@Singleton +public class FeedEntryContentCleaningService { + + private static final Safelist HTML_WHITELIST = buildWhiteList(); + private static final List ALLOWED_IFRAME_CSS_RULES = Arrays.asList("height", "width", "border"); + private static final List ALLOWED_IMG_CSS_RULES = Arrays.asList("display", "width", "height"); + private static final char[] FORBIDDEN_CSS_RULE_CHARACTERS = new char[] { '(', ')' }; + + public String clean(String content, String baseUri, boolean keepTextOnly) { + if (StringUtils.isNotBlank(content)) { + baseUri = StringUtils.trimToEmpty(baseUri); + + Document dirty = Jsoup.parseBodyFragment(content, baseUri); + Cleaner cleaner = new Cleaner(HTML_WHITELIST); + Document clean = cleaner.clean(dirty); + + for (Element e : clean.select("iframe[style]")) { + String style = e.attr("style"); + String escaped = escapeIFrameCss(style); + e.attr("style", escaped); + } + + for (Element e : clean.select("img[style]")) { + String style = e.attr("style"); + String escaped = escapeImgCss(style); + e.attr("style", escaped); + } + + clean.outputSettings(new OutputSettings().escapeMode(EscapeMode.base).prettyPrint(false)); + Element body = clean.body(); + if (keepTextOnly) { + content = body.text(); + } else { + content = body.html(); + } + } + return content; + } + + private static Safelist buildWhiteList() { + Safelist whitelist = new Safelist(); + 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", + "table", "tbody", "td", "tfoot", "th", "thead", "tr", "u", "ul"); + + whitelist.addAttributes("div", "dir"); + whitelist.addAttributes("pre", "dir"); + whitelist.addAttributes("code", "dir"); + whitelist.addAttributes("table", "dir"); + whitelist.addAttributes("p", "dir"); + whitelist.addAttributes("a", "href", "title"); + whitelist.addAttributes("blockquote", "cite"); + whitelist.addAttributes("col", "span", "width"); + whitelist.addAttributes("colgroup", "span", "width"); + whitelist.addAttributes("iframe", "src", "height", "width", "allowfullscreen", "frameborder", "style"); + whitelist.addAttributes("img", "align", "alt", "height", "src", "title", "width", "style"); + whitelist.addAttributes("ol", "start", "type"); + whitelist.addAttributes("q", "cite"); + whitelist.addAttributes("table", "border", "bordercolor", "summary", "width"); + whitelist.addAttributes("td", "border", "bordercolor", "abbr", "axis", "colspan", "rowspan", "width"); + whitelist.addAttributes("th", "border", "bordercolor", "abbr", "axis", "colspan", "rowspan", "scope", "width"); + whitelist.addAttributes("ul", "type"); + + whitelist.addProtocols("a", "href", "ftp", "http", "https", "magnet", "mailto"); + whitelist.addProtocols("blockquote", "cite", "http", "https"); + whitelist.addProtocols("img", "src", "http", "https"); + whitelist.addProtocols("q", "cite", "http", "https"); + + whitelist.addEnforcedAttribute("a", "target", "_blank"); + whitelist.addEnforcedAttribute("a", "rel", "noreferrer"); + return whitelist; + } + + private String escapeIFrameCss(String orig) { + String rule = ""; + try { + List rules = new ArrayList<>(); + CSSStyleDeclaration decl = buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig))); + + for (int i = 0; i < decl.getLength(); i++) { + String property = decl.item(i); + String value = decl.getPropertyValue(property); + if (StringUtils.isBlank(property) || StringUtils.isBlank(value)) { + continue; + } + + if (ALLOWED_IFRAME_CSS_RULES.contains(property) && StringUtils.containsNone(value, FORBIDDEN_CSS_RULE_CHARACTERS)) { + rules.add(property + ":" + decl.getPropertyValue(property) + ";"); + } + } + rule = StringUtils.join(rules, ""); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + return rule; + } + + private String escapeImgCss(String orig) { + String rule = ""; + try { + List rules = new ArrayList<>(); + CSSStyleDeclaration decl = buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig))); + + for (int i = 0; i < decl.getLength(); i++) { + String property = decl.item(i); + String value = decl.getPropertyValue(property); + if (StringUtils.isBlank(property) || StringUtils.isBlank(value)) { + continue; + } + + if (ALLOWED_IMG_CSS_RULES.contains(property) && StringUtils.containsNone(value, FORBIDDEN_CSS_RULE_CHARACTERS)) { + rules.add(property + ":" + decl.getPropertyValue(property) + ";"); + } + } + rule = StringUtils.join(rules, ""); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + return rule; + } + + private CSSOMParser buildCssParser() { + CSSOMParser parser = new CSSOMParser(new SACParserCSS21()); + + parser.setErrorHandler(new ErrorHandler() { + @Override + public void warning(CSSParseException exception) throws CSSException { + log.debug("warning while parsing css: {}", exception.getMessage(), exception); + } + + @Override + public void error(CSSParseException exception) throws CSSException { + log.debug("error while parsing css: {}", exception.getMessage(), exception); + } + + @Override + public void fatalError(CSSParseException exception) throws CSSException { + log.debug("fatal error while parsing css: {}", exception.getMessage(), exception); + } + }); + + return parser; + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/service/FeedEntryContentService.java b/commafeed-server/src/main/java/com/commafeed/backend/service/FeedEntryContentService.java index 67a45feb..df5ee744 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/service/FeedEntryContentService.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/service/FeedEntryContentService.java @@ -1,71 +1,71 @@ -package com.commafeed.backend.service; - -import java.util.Optional; - -import jakarta.inject.Singleton; - -import org.apache.commons.lang3.StringUtils; - -import com.commafeed.backend.Digests; -import com.commafeed.backend.dao.FeedEntryContentDAO; -import com.commafeed.backend.feed.FeedUtils; -import com.commafeed.backend.feed.parser.FeedParserResult.Content; -import com.commafeed.backend.feed.parser.FeedParserResult.Enclosure; -import com.commafeed.backend.feed.parser.FeedParserResult.Media; -import com.commafeed.backend.model.FeedEntryContent; - -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Singleton -public class FeedEntryContentService { - - private final FeedEntryContentDAO feedEntryContentDAO; - private final FeedEntryContentCleaningService cleaningService; - - /** - * this is NOT thread-safe - */ - public FeedEntryContent findOrCreate(Content content, String baseUrl) { - FeedEntryContent entryContent = buildContent(content, baseUrl); - Optional existing = feedEntryContentDAO.findExisting(entryContent.getContentHash(), entryContent.getTitleHash()) - .stream() - .filter(entryContent::equivalentTo) - .findFirst(); - if (existing.isPresent()) { - return existing.get(); - } else { - feedEntryContentDAO.saveOrUpdate(entryContent); - return entryContent; - } - } - - private FeedEntryContent buildContent(Content content, String baseUrl) { - FeedEntryContent entryContent = new FeedEntryContent(); - entryContent.setTitleHash(Digests.sha1Hex(StringUtils.trimToEmpty(content.title()))); - entryContent.setContentHash(Digests.sha1Hex(StringUtils.trimToEmpty(content.content()))); - entryContent.setTitle(FeedUtils.truncate(cleaningService.clean(content.title(), baseUrl, true), 2048)); - entryContent.setContent(cleaningService.clean(content.content(), baseUrl, false)); - entryContent.setAuthor(FeedUtils.truncate(cleaningService.clean(content.author(), baseUrl, true), 128)); - entryContent.setCategories(FeedUtils.truncate(content.categories(), 4096)); - entryContent.setDirection( - FeedUtils.isRTL(content.title(), content.content()) ? FeedEntryContent.Direction.rtl : FeedEntryContent.Direction.ltr); - - Enclosure enclosure = content.enclosure(); - if (enclosure != null) { - entryContent.setEnclosureUrl(FeedUtils.truncate(enclosure.url(), 2048)); - entryContent.setEnclosureType(enclosure.type()); - } - - Media media = content.media(); - if (media != null) { - entryContent.setMediaDescription(cleaningService.clean(media.description(), baseUrl, false)); - entryContent.setMediaThumbnailUrl(FeedUtils.truncate(media.thumbnailUrl(), 2048)); - entryContent.setMediaThumbnailWidth(media.thumbnailWidth()); - entryContent.setMediaThumbnailHeight(media.thumbnailHeight()); - } - - return entryContent; - } - -} +package com.commafeed.backend.service; + +import java.util.Optional; + +import jakarta.inject.Singleton; + +import org.apache.commons.lang3.StringUtils; + +import com.commafeed.backend.Digests; +import com.commafeed.backend.dao.FeedEntryContentDAO; +import com.commafeed.backend.feed.FeedUtils; +import com.commafeed.backend.feed.parser.FeedParserResult.Content; +import com.commafeed.backend.feed.parser.FeedParserResult.Enclosure; +import com.commafeed.backend.feed.parser.FeedParserResult.Media; +import com.commafeed.backend.model.FeedEntryContent; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Singleton +public class FeedEntryContentService { + + private final FeedEntryContentDAO feedEntryContentDAO; + private final FeedEntryContentCleaningService cleaningService; + + /** + * this is NOT thread-safe + */ + public FeedEntryContent findOrCreate(Content content, String baseUrl) { + FeedEntryContent entryContent = buildContent(content, baseUrl); + Optional existing = feedEntryContentDAO.findExisting(entryContent.getContentHash(), entryContent.getTitleHash()) + .stream() + .filter(entryContent::equivalentTo) + .findFirst(); + if (existing.isPresent()) { + return existing.get(); + } else { + feedEntryContentDAO.saveOrUpdate(entryContent); + return entryContent; + } + } + + private FeedEntryContent buildContent(Content content, String baseUrl) { + FeedEntryContent entryContent = new FeedEntryContent(); + entryContent.setTitleHash(Digests.sha1Hex(StringUtils.trimToEmpty(content.title()))); + entryContent.setContentHash(Digests.sha1Hex(StringUtils.trimToEmpty(content.content()))); + entryContent.setTitle(FeedUtils.truncate(cleaningService.clean(content.title(), baseUrl, true), 2048)); + entryContent.setContent(cleaningService.clean(content.content(), baseUrl, false)); + entryContent.setAuthor(FeedUtils.truncate(cleaningService.clean(content.author(), baseUrl, true), 128)); + entryContent.setCategories(FeedUtils.truncate(content.categories(), 4096)); + entryContent.setDirection( + FeedUtils.isRTL(content.title(), content.content()) ? FeedEntryContent.Direction.rtl : FeedEntryContent.Direction.ltr); + + Enclosure enclosure = content.enclosure(); + if (enclosure != null) { + entryContent.setEnclosureUrl(FeedUtils.truncate(enclosure.url(), 2048)); + entryContent.setEnclosureType(enclosure.type()); + } + + Media media = content.media(); + if (media != null) { + entryContent.setMediaDescription(cleaningService.clean(media.description(), baseUrl, false)); + entryContent.setMediaThumbnailUrl(FeedUtils.truncate(media.thumbnailUrl(), 2048)); + entryContent.setMediaThumbnailWidth(media.thumbnailWidth()); + entryContent.setMediaThumbnailHeight(media.thumbnailHeight()); + } + + return entryContent; + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/service/FeedEntryFilteringService.java b/commafeed-server/src/main/java/com/commafeed/backend/service/FeedEntryFilteringService.java index f7e7014b..8c757a2c 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/service/FeedEntryFilteringService.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/service/FeedEntryFilteringService.java @@ -1,124 +1,124 @@ -package com.commafeed.backend.service; - -import java.time.Year; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -import jakarta.inject.Singleton; - -import org.apache.commons.jexl2.JexlContext; -import org.apache.commons.jexl2.JexlEngine; -import org.apache.commons.jexl2.JexlException; -import org.apache.commons.jexl2.JexlInfo; -import org.apache.commons.jexl2.MapContext; -import org.apache.commons.jexl2.Script; -import org.apache.commons.jexl2.introspection.JexlMethod; -import org.apache.commons.jexl2.introspection.JexlPropertyGet; -import org.apache.commons.jexl2.introspection.Uberspect; -import org.apache.commons.jexl2.introspection.UberspectImpl; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.logging.LogFactory; -import org.jsoup.Jsoup; - -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.model.FeedEntry; - -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Singleton -public class FeedEntryFilteringService { - - private static final JexlEngine ENGINE = initEngine(); - - private final ExecutorService executor = Executors.newCachedThreadPool(); - private final CommaFeedConfiguration config; - - private static JexlEngine initEngine() { - // classloader that prevents object creation - ClassLoader cl = new ClassLoader() { - @Override - protected Class loadClass(String name, boolean resolve) { - return null; - } - }; - - // uberspect that prevents access to .class and .getClass() - Uberspect uberspect = new UberspectImpl(LogFactory.getLog(JexlEngine.class)) { - @Override - public JexlPropertyGet getPropertyGet(Object obj, Object identifier, JexlInfo info) { - if ("class".equals(identifier)) { - return null; - } - return super.getPropertyGet(obj, identifier, info); - } - - @Override - public JexlMethod getMethod(Object obj, String method, Object[] args, JexlInfo info) { - if ("getClass".equals(method)) { - return null; - } - return super.getMethod(obj, method, args, info); - } - }; - - JexlEngine engine = new JexlEngine(uberspect, null, null, null); - engine.setStrict(true); - engine.setClassLoader(cl); - return engine; - } - - public boolean filterMatchesEntry(String filter, FeedEntry entry) throws FeedEntryFilterException { - if (StringUtils.isBlank(filter)) { - return true; - } - - Script script; - try { - script = ENGINE.createScript(filter); - } catch (JexlException e) { - throw new FeedEntryFilterException("Exception while parsing expression " + filter, e); - } - - JexlContext context = new MapContext(); - 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("content", - entry.getContent().getContent() == null ? "" : Jsoup.parse(entry.getContent().getContent()).text().toLowerCase()); - context.set("url", entry.getUrl() == null ? "" : entry.getUrl().toLowerCase()); - context.set("categories", entry.getContent().getCategories() == null ? "" : entry.getContent().getCategories().toLowerCase()); - - context.set("year", Year.now().getValue()); - - Callable callable = script.callable(context); - Future future = executor.submit(callable); - Object result; - try { - result = future.get(config.feedRefresh().filteringExpressionEvaluationTimeout().toMillis(), TimeUnit.MILLISECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new FeedEntryFilterException("interrupted while evaluating expression " + filter, e); - } catch (ExecutionException e) { - throw new FeedEntryFilterException("Exception while evaluating expression " + filter, e); - } catch (TimeoutException e) { - throw new FeedEntryFilterException("Took too long evaluating expression " + filter, e); - } - try { - return (boolean) result; - } catch (ClassCastException e) { - throw new FeedEntryFilterException(e.getMessage(), e); - } - } - - @SuppressWarnings("serial") - public static class FeedEntryFilterException extends Exception { - public FeedEntryFilterException(String message, Throwable t) { - super(message, t); - } - } -} +package com.commafeed.backend.service; + +import java.time.Year; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import jakarta.inject.Singleton; + +import org.apache.commons.jexl2.JexlContext; +import org.apache.commons.jexl2.JexlEngine; +import org.apache.commons.jexl2.JexlException; +import org.apache.commons.jexl2.JexlInfo; +import org.apache.commons.jexl2.MapContext; +import org.apache.commons.jexl2.Script; +import org.apache.commons.jexl2.introspection.JexlMethod; +import org.apache.commons.jexl2.introspection.JexlPropertyGet; +import org.apache.commons.jexl2.introspection.Uberspect; +import org.apache.commons.jexl2.introspection.UberspectImpl; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.logging.LogFactory; +import org.jsoup.Jsoup; + +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.backend.model.FeedEntry; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Singleton +public class FeedEntryFilteringService { + + private static final JexlEngine ENGINE = initEngine(); + + private final ExecutorService executor = Executors.newCachedThreadPool(); + private final CommaFeedConfiguration config; + + private static JexlEngine initEngine() { + // classloader that prevents object creation + ClassLoader cl = new ClassLoader() { + @Override + protected Class loadClass(String name, boolean resolve) { + return null; + } + }; + + // uberspect that prevents access to .class and .getClass() + Uberspect uberspect = new UberspectImpl(LogFactory.getLog(JexlEngine.class)) { + @Override + public JexlPropertyGet getPropertyGet(Object obj, Object identifier, JexlInfo info) { + if ("class".equals(identifier)) { + return null; + } + return super.getPropertyGet(obj, identifier, info); + } + + @Override + public JexlMethod getMethod(Object obj, String method, Object[] args, JexlInfo info) { + if ("getClass".equals(method)) { + return null; + } + return super.getMethod(obj, method, args, info); + } + }; + + JexlEngine engine = new JexlEngine(uberspect, null, null, null); + engine.setStrict(true); + engine.setClassLoader(cl); + return engine; + } + + public boolean filterMatchesEntry(String filter, FeedEntry entry) throws FeedEntryFilterException { + if (StringUtils.isBlank(filter)) { + return true; + } + + Script script; + try { + script = ENGINE.createScript(filter); + } catch (JexlException e) { + throw new FeedEntryFilterException("Exception while parsing expression " + filter, e); + } + + JexlContext context = new MapContext(); + 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("content", + entry.getContent().getContent() == null ? "" : Jsoup.parse(entry.getContent().getContent()).text().toLowerCase()); + context.set("url", entry.getUrl() == null ? "" : entry.getUrl().toLowerCase()); + context.set("categories", entry.getContent().getCategories() == null ? "" : entry.getContent().getCategories().toLowerCase()); + + context.set("year", Year.now().getValue()); + + Callable callable = script.callable(context); + Future future = executor.submit(callable); + Object result; + try { + result = future.get(config.feedRefresh().filteringExpressionEvaluationTimeout().toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new FeedEntryFilterException("interrupted while evaluating expression " + filter, e); + } catch (ExecutionException e) { + throw new FeedEntryFilterException("Exception while evaluating expression " + filter, e); + } catch (TimeoutException e) { + throw new FeedEntryFilterException("Took too long evaluating expression " + filter, e); + } + try { + return (boolean) result; + } catch (ClassCastException e) { + throw new FeedEntryFilterException(e.getMessage(), e); + } + } + + @SuppressWarnings("serial") + public static class FeedEntryFilterException extends Exception { + public FeedEntryFilterException(String message, Throwable t) { + super(message, t); + } + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/service/FeedEntryService.java b/commafeed-server/src/main/java/com/commafeed/backend/service/FeedEntryService.java index c7715383..0cd5c044 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/service/FeedEntryService.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/service/FeedEntryService.java @@ -1,131 +1,131 @@ -package com.commafeed.backend.service; - -import java.time.Instant; -import java.util.List; - -import jakarta.inject.Singleton; - -import com.commafeed.backend.Digests; -import com.commafeed.backend.dao.FeedEntryDAO; -import com.commafeed.backend.dao.FeedEntryStatusDAO; -import com.commafeed.backend.dao.FeedSubscriptionDAO; -import com.commafeed.backend.feed.FeedEntryKeyword; -import com.commafeed.backend.feed.FeedUtils; -import com.commafeed.backend.feed.parser.FeedParserResult.Entry; -import com.commafeed.backend.model.Feed; -import com.commafeed.backend.model.FeedEntry; -import com.commafeed.backend.model.FeedEntryStatus; -import com.commafeed.backend.model.FeedSubscription; -import com.commafeed.backend.model.User; -import com.commafeed.backend.service.FeedEntryFilteringService.FeedEntryFilterException; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@RequiredArgsConstructor -@Singleton -public class FeedEntryService { - - private final FeedSubscriptionDAO feedSubscriptionDAO; - private final FeedEntryDAO feedEntryDAO; - private final FeedEntryStatusDAO feedEntryStatusDAO; - private final FeedEntryContentService feedEntryContentService; - private final FeedEntryFilteringService feedEntryFilteringService; - - public FeedEntry find(Feed feed, Entry entry) { - String guidHash = Digests.sha1Hex(entry.guid()); - return feedEntryDAO.findExisting(guidHash, feed); - } - - public FeedEntry create(Feed feed, Entry entry) { - FeedEntry feedEntry = new FeedEntry(); - feedEntry.setGuid(FeedUtils.truncate(entry.guid(), 2048)); - feedEntry.setGuidHash(Digests.sha1Hex(entry.guid())); - feedEntry.setUrl(FeedUtils.truncate(entry.url(), 2048)); - feedEntry.setPublished(entry.published()); - feedEntry.setInserted(Instant.now()); - feedEntry.setFeed(feed); - feedEntry.setContent(feedEntryContentService.findOrCreate(entry.content(), feed.getLink())); - - feedEntryDAO.saveOrUpdate(feedEntry); - return feedEntry; - } - - public boolean applyFilter(FeedSubscription sub, FeedEntry entry) { - boolean matches = true; - try { - matches = feedEntryFilteringService.filterMatchesEntry(sub.getFilter(), entry); - } catch (FeedEntryFilterException e) { - log.error("could not evaluate filter {}", sub.getFilter(), e); - } - - if (!matches) { - FeedEntryStatus status = new FeedEntryStatus(sub.getUser(), sub, entry); - status.setRead(true); - feedEntryStatusDAO.saveOrUpdate(status); - } - - return matches; - } - - public void markEntry(User user, Long entryId, boolean read) { - FeedEntry entry = feedEntryDAO.findById(entryId); - if (entry == null) { - return; - } - - FeedSubscription sub = feedSubscriptionDAO.findByFeed(user, entry.getFeed()); - if (sub == null) { - return; - } - - FeedEntryStatus status = feedEntryStatusDAO.getStatus(user, sub, entry); - if (status.isMarkable()) { - status.setRead(read); - feedEntryStatusDAO.saveOrUpdate(status); - } - } - - public void starEntry(User user, Long entryId, Long subscriptionId, boolean starred) { - - FeedSubscription sub = feedSubscriptionDAO.findById(user, subscriptionId); - if (sub == null) { - return; - } - - FeedEntry entry = feedEntryDAO.findById(entryId); - if (entry == null) { - return; - } - - FeedEntryStatus status = feedEntryStatusDAO.getStatus(user, sub, entry); - status.setStarred(starred); - feedEntryStatusDAO.saveOrUpdate(status); - } - - public void markSubscriptionEntries(User user, List subscriptions, Instant olderThan, Instant insertedBefore, - List keywords) { - List statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, true, keywords, null, -1, -1, null, - false, null, null, null); - markList(statuses, olderThan, insertedBefore); - } - - public void markStarredEntries(User user, Instant olderThan, Instant insertedBefore) { - List statuses = feedEntryStatusDAO.findStarred(user, null, -1, -1, null, false); - markList(statuses, olderThan, insertedBefore); - } - - private void markList(List statuses, Instant olderThan, Instant insertedBefore) { - List statusesToMark = statuses.stream().filter(FeedEntryStatus::isMarkable).filter(s -> { - Instant entryDate = s.getEntry().getPublished(); - return olderThan == null || entryDate == null || entryDate.isBefore(olderThan); - }).filter(s -> { - Instant insertedDate = s.getEntry().getInserted(); - return insertedBefore == null || insertedDate == null || insertedDate.isBefore(insertedBefore); - }).toList(); - - statusesToMark.forEach(s -> s.setRead(true)); - feedEntryStatusDAO.saveOrUpdate(statusesToMark); - } -} +package com.commafeed.backend.service; + +import java.time.Instant; +import java.util.List; + +import jakarta.inject.Singleton; + +import com.commafeed.backend.Digests; +import com.commafeed.backend.dao.FeedEntryDAO; +import com.commafeed.backend.dao.FeedEntryStatusDAO; +import com.commafeed.backend.dao.FeedSubscriptionDAO; +import com.commafeed.backend.feed.FeedEntryKeyword; +import com.commafeed.backend.feed.FeedUtils; +import com.commafeed.backend.feed.parser.FeedParserResult.Entry; +import com.commafeed.backend.model.Feed; +import com.commafeed.backend.model.FeedEntry; +import com.commafeed.backend.model.FeedEntryStatus; +import com.commafeed.backend.model.FeedSubscription; +import com.commafeed.backend.model.User; +import com.commafeed.backend.service.FeedEntryFilteringService.FeedEntryFilterException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Singleton +public class FeedEntryService { + + private final FeedSubscriptionDAO feedSubscriptionDAO; + private final FeedEntryDAO feedEntryDAO; + private final FeedEntryStatusDAO feedEntryStatusDAO; + private final FeedEntryContentService feedEntryContentService; + private final FeedEntryFilteringService feedEntryFilteringService; + + public FeedEntry find(Feed feed, Entry entry) { + String guidHash = Digests.sha1Hex(entry.guid()); + return feedEntryDAO.findExisting(guidHash, feed); + } + + public FeedEntry create(Feed feed, Entry entry) { + FeedEntry feedEntry = new FeedEntry(); + feedEntry.setGuid(FeedUtils.truncate(entry.guid(), 2048)); + feedEntry.setGuidHash(Digests.sha1Hex(entry.guid())); + feedEntry.setUrl(FeedUtils.truncate(entry.url(), 2048)); + feedEntry.setPublished(entry.published()); + feedEntry.setInserted(Instant.now()); + feedEntry.setFeed(feed); + feedEntry.setContent(feedEntryContentService.findOrCreate(entry.content(), feed.getLink())); + + feedEntryDAO.saveOrUpdate(feedEntry); + return feedEntry; + } + + public boolean applyFilter(FeedSubscription sub, FeedEntry entry) { + boolean matches = true; + try { + matches = feedEntryFilteringService.filterMatchesEntry(sub.getFilter(), entry); + } catch (FeedEntryFilterException e) { + log.error("could not evaluate filter {}", sub.getFilter(), e); + } + + if (!matches) { + FeedEntryStatus status = new FeedEntryStatus(sub.getUser(), sub, entry); + status.setRead(true); + feedEntryStatusDAO.saveOrUpdate(status); + } + + return matches; + } + + public void markEntry(User user, Long entryId, boolean read) { + FeedEntry entry = feedEntryDAO.findById(entryId); + if (entry == null) { + return; + } + + FeedSubscription sub = feedSubscriptionDAO.findByFeed(user, entry.getFeed()); + if (sub == null) { + return; + } + + FeedEntryStatus status = feedEntryStatusDAO.getStatus(user, sub, entry); + if (status.isMarkable()) { + status.setRead(read); + feedEntryStatusDAO.saveOrUpdate(status); + } + } + + public void starEntry(User user, Long entryId, Long subscriptionId, boolean starred) { + + FeedSubscription sub = feedSubscriptionDAO.findById(user, subscriptionId); + if (sub == null) { + return; + } + + FeedEntry entry = feedEntryDAO.findById(entryId); + if (entry == null) { + return; + } + + FeedEntryStatus status = feedEntryStatusDAO.getStatus(user, sub, entry); + status.setStarred(starred); + feedEntryStatusDAO.saveOrUpdate(status); + } + + public void markSubscriptionEntries(User user, List subscriptions, Instant olderThan, Instant insertedBefore, + List keywords) { + List statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, true, keywords, null, -1, -1, null, + false, null, null, null); + markList(statuses, olderThan, insertedBefore); + } + + public void markStarredEntries(User user, Instant olderThan, Instant insertedBefore) { + List statuses = feedEntryStatusDAO.findStarred(user, null, -1, -1, null, false); + markList(statuses, olderThan, insertedBefore); + } + + private void markList(List statuses, Instant olderThan, Instant insertedBefore) { + List statusesToMark = statuses.stream().filter(FeedEntryStatus::isMarkable).filter(s -> { + Instant entryDate = s.getEntry().getPublished(); + return olderThan == null || entryDate == null || entryDate.isBefore(olderThan); + }).filter(s -> { + Instant insertedDate = s.getEntry().getInserted(); + return insertedBefore == null || insertedDate == null || insertedDate.isBefore(insertedBefore); + }).toList(); + + statusesToMark.forEach(s -> s.setRead(true)); + feedEntryStatusDAO.saveOrUpdate(statusesToMark); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/service/FeedEntryTagService.java b/commafeed-server/src/main/java/com/commafeed/backend/service/FeedEntryTagService.java index e85f895c..cdec71ba 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/service/FeedEntryTagService.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/service/FeedEntryTagService.java @@ -1,43 +1,43 @@ -package com.commafeed.backend.service; - -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -import jakarta.inject.Singleton; - -import com.commafeed.backend.dao.FeedEntryDAO; -import com.commafeed.backend.dao.FeedEntryTagDAO; -import com.commafeed.backend.model.FeedEntry; -import com.commafeed.backend.model.FeedEntryTag; -import com.commafeed.backend.model.User; - -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Singleton -public class FeedEntryTagService { - - private final FeedEntryDAO feedEntryDAO; - private final FeedEntryTagDAO feedEntryTagDAO; - - public void updateTags(User user, Long entryId, List tagNames) { - FeedEntry entry = feedEntryDAO.findById(entryId); - if (entry == null) { - return; - } - - List existingTags = feedEntryTagDAO.findByEntry(user, entry); - Set existingTagNames = existingTags.stream().map(FeedEntryTag::getName).collect(Collectors.toSet()); - - List addList = tagNames.stream() - .filter(name -> !existingTagNames.contains(name)) - .map(name -> new FeedEntryTag(user, entry, name)) - .toList(); - List removeList = existingTags.stream().filter(tag -> !tagNames.contains(tag.getName())).toList(); - - feedEntryTagDAO.saveOrUpdate(addList); - feedEntryTagDAO.delete(removeList); - } - -} +package com.commafeed.backend.service; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import jakarta.inject.Singleton; + +import com.commafeed.backend.dao.FeedEntryDAO; +import com.commafeed.backend.dao.FeedEntryTagDAO; +import com.commafeed.backend.model.FeedEntry; +import com.commafeed.backend.model.FeedEntryTag; +import com.commafeed.backend.model.User; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Singleton +public class FeedEntryTagService { + + private final FeedEntryDAO feedEntryDAO; + private final FeedEntryTagDAO feedEntryTagDAO; + + public void updateTags(User user, Long entryId, List tagNames) { + FeedEntry entry = feedEntryDAO.findById(entryId); + if (entry == null) { + return; + } + + List existingTags = feedEntryTagDAO.findByEntry(user, entry); + Set existingTagNames = existingTags.stream().map(FeedEntryTag::getName).collect(Collectors.toSet()); + + List addList = tagNames.stream() + .filter(name -> !existingTagNames.contains(name)) + .map(name -> new FeedEntryTag(user, entry, name)) + .toList(); + List removeList = existingTags.stream().filter(tag -> !tagNames.contains(tag.getName())).toList(); + + feedEntryTagDAO.saveOrUpdate(addList); + feedEntryTagDAO.delete(removeList); + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/service/FeedService.java b/commafeed-server/src/main/java/com/commafeed/backend/service/FeedService.java index 8630ce5c..cf21cddd 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/service/FeedService.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/service/FeedService.java @@ -1,80 +1,80 @@ -package com.commafeed.backend.service; - -import java.io.IOException; -import java.time.Instant; -import java.util.List; -import java.util.Objects; - -import jakarta.inject.Singleton; - -import com.commafeed.backend.Digests; -import com.commafeed.backend.dao.FeedDAO; -import com.commafeed.backend.favicon.AbstractFaviconFetcher; -import com.commafeed.backend.favicon.Favicon; -import com.commafeed.backend.feed.FeedUtils; -import com.commafeed.backend.model.Feed; -import com.commafeed.backend.model.Models; -import com.google.common.io.Resources; - -import io.quarkus.arc.All; - -@Singleton -public class FeedService { - - private final FeedDAO feedDAO; - private final List faviconFetchers; - - private final Favicon defaultFavicon; - - public FeedService(FeedDAO feedDAO, @All List faviconFetchers) { - this.feedDAO = feedDAO; - this.faviconFetchers = faviconFetchers; - - try { - defaultFavicon = new Favicon( - Resources.toByteArray(Objects.requireNonNull(getClass().getResource("/images/default_favicon.gif"))), "image/gif"); - } catch (IOException e) { - throw new RuntimeException("could not load default favicon", e); - } - } - - public synchronized Feed findOrCreate(String url) { - String normalizedUrl = FeedUtils.normalizeURL(url); - String normalizedUrlHash = Digests.sha1Hex(normalizedUrl); - Feed feed = feedDAO.findByUrl(normalizedUrl, normalizedUrlHash); - if (feed == null) { - feed = new Feed(); - feed.setUrl(url); - feed.setNormalizedUrl(normalizedUrl); - feed.setNormalizedUrlHash(normalizedUrlHash); - feed.setDisabledUntil(Models.MINIMUM_INSTANT); - feedDAO.persist(feed); - } - return feed; - } - - public void update(Feed feed) { - String normalized = FeedUtils.normalizeURL(feed.getUrl()); - feed.setNormalizedUrl(normalized); - feed.setNormalizedUrlHash(Digests.sha1Hex(normalized)); - feed.setLastUpdated(Instant.now()); - feed.setEtagHeader(FeedUtils.truncate(feed.getEtagHeader(), 255)); - feedDAO.merge(feed); - } - - public Favicon fetchFavicon(Feed feed) { - - Favicon icon = null; - for (AbstractFaviconFetcher faviconFetcher : faviconFetchers) { - icon = faviconFetcher.fetch(feed); - if (icon != null) { - break; - } - } - if (icon == null) { - icon = defaultFavicon; - } - return icon; - } - -} +package com.commafeed.backend.service; + +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +import jakarta.inject.Singleton; + +import com.commafeed.backend.Digests; +import com.commafeed.backend.dao.FeedDAO; +import com.commafeed.backend.favicon.AbstractFaviconFetcher; +import com.commafeed.backend.favicon.Favicon; +import com.commafeed.backend.feed.FeedUtils; +import com.commafeed.backend.model.Feed; +import com.commafeed.backend.model.Models; +import com.google.common.io.Resources; + +import io.quarkus.arc.All; + +@Singleton +public class FeedService { + + private final FeedDAO feedDAO; + private final List faviconFetchers; + + private final Favicon defaultFavicon; + + public FeedService(FeedDAO feedDAO, @All List faviconFetchers) { + this.feedDAO = feedDAO; + this.faviconFetchers = faviconFetchers; + + try { + defaultFavicon = new Favicon( + Resources.toByteArray(Objects.requireNonNull(getClass().getResource("/images/default_favicon.gif"))), "image/gif"); + } catch (IOException e) { + throw new RuntimeException("could not load default favicon", e); + } + } + + public synchronized Feed findOrCreate(String url) { + String normalizedUrl = FeedUtils.normalizeURL(url); + String normalizedUrlHash = Digests.sha1Hex(normalizedUrl); + Feed feed = feedDAO.findByUrl(normalizedUrl, normalizedUrlHash); + if (feed == null) { + feed = new Feed(); + feed.setUrl(url); + feed.setNormalizedUrl(normalizedUrl); + feed.setNormalizedUrlHash(normalizedUrlHash); + feed.setDisabledUntil(Models.MINIMUM_INSTANT); + feedDAO.persist(feed); + } + return feed; + } + + public void update(Feed feed) { + String normalized = FeedUtils.normalizeURL(feed.getUrl()); + feed.setNormalizedUrl(normalized); + feed.setNormalizedUrlHash(Digests.sha1Hex(normalized)); + feed.setLastUpdated(Instant.now()); + feed.setEtagHeader(FeedUtils.truncate(feed.getEtagHeader(), 255)); + feedDAO.merge(feed); + } + + public Favicon fetchFavicon(Feed feed) { + + Favicon icon = null; + for (AbstractFaviconFetcher faviconFetcher : faviconFetchers) { + icon = faviconFetcher.fetch(feed); + if (icon != null) { + break; + } + } + if (icon == null) { + icon = defaultFavicon; + } + return icon; + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/service/FeedSubscriptionService.java b/commafeed-server/src/main/java/com/commafeed/backend/service/FeedSubscriptionService.java index f1dfba65..1e5012bf 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/service/FeedSubscriptionService.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/service/FeedSubscriptionService.java @@ -1,148 +1,148 @@ -package com.commafeed.backend.service; - -import java.time.Instant; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import jakarta.inject.Singleton; - -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.dao.FeedDAO; -import com.commafeed.backend.dao.FeedEntryStatusDAO; -import com.commafeed.backend.dao.FeedSubscriptionDAO; -import com.commafeed.backend.feed.FeedRefreshEngine; -import com.commafeed.backend.feed.FeedUtils; -import com.commafeed.backend.model.Feed; -import com.commafeed.backend.model.FeedCategory; -import com.commafeed.backend.model.FeedSubscription; -import com.commafeed.backend.model.User; -import com.commafeed.frontend.model.UnreadCount; - -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Singleton -public class FeedSubscriptionService { - - private final FeedDAO feedDAO; - private final FeedEntryStatusDAO feedEntryStatusDAO; - private final FeedSubscriptionDAO feedSubscriptionDAO; - private final FeedService feedService; - private final FeedRefreshEngine feedRefreshEngine; - private final CommaFeedConfiguration config; - - public FeedSubscriptionService(FeedDAO feedDAO, FeedEntryStatusDAO feedEntryStatusDAO, FeedSubscriptionDAO feedSubscriptionDAO, - FeedService feedService, FeedRefreshEngine feedRefreshEngine, CommaFeedConfiguration config) { - this.feedDAO = feedDAO; - this.feedEntryStatusDAO = feedEntryStatusDAO; - this.feedSubscriptionDAO = feedSubscriptionDAO; - this.feedService = feedService; - this.feedRefreshEngine = feedRefreshEngine; - this.config = config; - - // 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 - feedSubscriptionDAO.onPostCommitInsert(sub -> { - Feed feed = sub.getFeed(); - if (feed.getDisabledUntil() == null || feed.getDisabledUntil().isBefore(Instant.now())) { - feedRefreshEngine.refreshImmediately(feed); - } - }); - } - - public long subscribe(User user, String url, String title) { - return subscribe(user, url, title, null, 0); - } - - public long subscribe(User user, String url, String title, FeedCategory parent) { - return subscribe(user, url, title, parent, 0); - } - - public long subscribe(User user, String url, String title, FeedCategory category, int position) { - Integer maxFeedsPerUser = config.database().cleanup().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)", - maxFeedsPerUser); - throw new FeedSubscriptionException(message); - } - - Feed feed = feedService.findOrCreate(url); - - // upgrade feed to https if it was using http - if (FeedUtils.isHttp(feed.getUrl()) && FeedUtils.isHttps(url)) { - feed.setUrl(url); - feedDAO.saveOrUpdate(feed); - } - - FeedSubscription sub = feedSubscriptionDAO.findByFeed(user, feed); - if (sub == null) { - sub = new FeedSubscription(); - sub.setFeed(feed); - sub.setUser(user); - } - sub.setCategory(category); - sub.setPosition(position); - sub.setTitle(FeedUtils.truncate(title, 128)); - feedSubscriptionDAO.saveOrUpdate(sub); - - return sub.getId(); - } - - public boolean unsubscribe(User user, Long subId) { - FeedSubscription sub = feedSubscriptionDAO.findById(user, subId); - if (sub != null) { - feedSubscriptionDAO.delete(sub); - return true; - } else { - return false; - } - } - - public void refreshAll(User user) throws ForceFeedRefreshTooSoonException { - Instant lastForceRefresh = user.getLastForceRefresh(); - if (lastForceRefresh != null && lastForceRefresh.plus(config.feedRefresh().forceRefreshCooldownDuration()).isAfter(Instant.now())) { - throw new ForceFeedRefreshTooSoonException(); - } - - List subs = feedSubscriptionDAO.findAll(user); - for (FeedSubscription sub : subs) { - Feed feed = sub.getFeed(); - feedRefreshEngine.refreshImmediately(feed); - } - - user.setLastForceRefresh(Instant.now()); - } - - public void refreshAllUpForRefresh(User user) { - List subs = feedSubscriptionDAO.findAll(user); - for (FeedSubscription sub : subs) { - Instant disabledUntil = sub.getFeed().getDisabledUntil(); - if (disabledUntil == null || disabledUntil.isBefore(Instant.now())) { - Feed feed = sub.getFeed(); - feedRefreshEngine.refreshImmediately(feed); - } - } - } - - public Map getUnreadCount(User user) { - return feedSubscriptionDAO.findAll(user) - .stream() - .collect(Collectors.toMap(FeedSubscription::getId, feedEntryStatusDAO::getUnreadCount)); - } - - @SuppressWarnings("serial") - public static class FeedSubscriptionException extends RuntimeException { - private FeedSubscriptionException(String msg) { - super(msg); - } - } - - @SuppressWarnings("serial") - public static class ForceFeedRefreshTooSoonException extends Exception { - private ForceFeedRefreshTooSoonException() { - super(); - } - } - -} +package com.commafeed.backend.service; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import jakarta.inject.Singleton; + +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.backend.dao.FeedDAO; +import com.commafeed.backend.dao.FeedEntryStatusDAO; +import com.commafeed.backend.dao.FeedSubscriptionDAO; +import com.commafeed.backend.feed.FeedRefreshEngine; +import com.commafeed.backend.feed.FeedUtils; +import com.commafeed.backend.model.Feed; +import com.commafeed.backend.model.FeedCategory; +import com.commafeed.backend.model.FeedSubscription; +import com.commafeed.backend.model.User; +import com.commafeed.frontend.model.UnreadCount; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Singleton +public class FeedSubscriptionService { + + private final FeedDAO feedDAO; + private final FeedEntryStatusDAO feedEntryStatusDAO; + private final FeedSubscriptionDAO feedSubscriptionDAO; + private final FeedService feedService; + private final FeedRefreshEngine feedRefreshEngine; + private final CommaFeedConfiguration config; + + public FeedSubscriptionService(FeedDAO feedDAO, FeedEntryStatusDAO feedEntryStatusDAO, FeedSubscriptionDAO feedSubscriptionDAO, + FeedService feedService, FeedRefreshEngine feedRefreshEngine, CommaFeedConfiguration config) { + this.feedDAO = feedDAO; + this.feedEntryStatusDAO = feedEntryStatusDAO; + this.feedSubscriptionDAO = feedSubscriptionDAO; + this.feedService = feedService; + this.feedRefreshEngine = feedRefreshEngine; + this.config = config; + + // 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 + feedSubscriptionDAO.onPostCommitInsert(sub -> { + Feed feed = sub.getFeed(); + if (feed.getDisabledUntil() == null || feed.getDisabledUntil().isBefore(Instant.now())) { + feedRefreshEngine.refreshImmediately(feed); + } + }); + } + + public long subscribe(User user, String url, String title) { + return subscribe(user, url, title, null, 0); + } + + public long subscribe(User user, String url, String title, FeedCategory parent) { + return subscribe(user, url, title, parent, 0); + } + + public long subscribe(User user, String url, String title, FeedCategory category, int position) { + Integer maxFeedsPerUser = config.database().cleanup().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)", + maxFeedsPerUser); + throw new FeedSubscriptionException(message); + } + + Feed feed = feedService.findOrCreate(url); + + // upgrade feed to https if it was using http + if (FeedUtils.isHttp(feed.getUrl()) && FeedUtils.isHttps(url)) { + feed.setUrl(url); + feedDAO.saveOrUpdate(feed); + } + + FeedSubscription sub = feedSubscriptionDAO.findByFeed(user, feed); + if (sub == null) { + sub = new FeedSubscription(); + sub.setFeed(feed); + sub.setUser(user); + } + sub.setCategory(category); + sub.setPosition(position); + sub.setTitle(FeedUtils.truncate(title, 128)); + feedSubscriptionDAO.saveOrUpdate(sub); + + return sub.getId(); + } + + public boolean unsubscribe(User user, Long subId) { + FeedSubscription sub = feedSubscriptionDAO.findById(user, subId); + if (sub != null) { + feedSubscriptionDAO.delete(sub); + return true; + } else { + return false; + } + } + + public void refreshAll(User user) throws ForceFeedRefreshTooSoonException { + Instant lastForceRefresh = user.getLastForceRefresh(); + if (lastForceRefresh != null && lastForceRefresh.plus(config.feedRefresh().forceRefreshCooldownDuration()).isAfter(Instant.now())) { + throw new ForceFeedRefreshTooSoonException(); + } + + List subs = feedSubscriptionDAO.findAll(user); + for (FeedSubscription sub : subs) { + Feed feed = sub.getFeed(); + feedRefreshEngine.refreshImmediately(feed); + } + + user.setLastForceRefresh(Instant.now()); + } + + public void refreshAllUpForRefresh(User user) { + List subs = feedSubscriptionDAO.findAll(user); + for (FeedSubscription sub : subs) { + Instant disabledUntil = sub.getFeed().getDisabledUntil(); + if (disabledUntil == null || disabledUntil.isBefore(Instant.now())) { + Feed feed = sub.getFeed(); + feedRefreshEngine.refreshImmediately(feed); + } + } + } + + public Map getUnreadCount(User user) { + return feedSubscriptionDAO.findAll(user) + .stream() + .collect(Collectors.toMap(FeedSubscription::getId, feedEntryStatusDAO::getUnreadCount)); + } + + @SuppressWarnings("serial") + public static class FeedSubscriptionException extends RuntimeException { + private FeedSubscriptionException(String msg) { + super(msg); + } + } + + @SuppressWarnings("serial") + public static class ForceFeedRefreshTooSoonException extends Exception { + private ForceFeedRefreshTooSoonException() { + super(); + } + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/service/MailService.java b/commafeed-server/src/main/java/com/commafeed/backend/service/MailService.java index c365535b..24147d4b 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/service/MailService.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/service/MailService.java @@ -1,21 +1,21 @@ -package com.commafeed.backend.service; - -import jakarta.inject.Singleton; - -import com.commafeed.backend.model.User; - -import io.quarkus.mailer.Mail; -import io.quarkus.mailer.Mailer; -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Singleton -public class MailService { - - private final Mailer mailer; - - public void sendMail(User user, String subject, String content) { - Mail mail = Mail.withHtml(user.getEmail(), "CommaFeed - " + subject, content); - mailer.send(mail); - } -} +package com.commafeed.backend.service; + +import jakarta.inject.Singleton; + +import com.commafeed.backend.model.User; + +import io.quarkus.mailer.Mail; +import io.quarkus.mailer.Mailer; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Singleton +public class MailService { + + private final Mailer mailer; + + public void sendMail(User user, String subject, String content) { + Mail mail = Mail.withHtml(user.getEmail(), "CommaFeed - " + subject, content); + mailer.send(mail); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/service/PasswordEncryptionService.java b/commafeed-server/src/main/java/com/commafeed/backend/service/PasswordEncryptionService.java index f55d3f66..b55a9909 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/service/PasswordEncryptionService.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/service/PasswordEncryptionService.java @@ -1,95 +1,95 @@ -package com.commafeed.backend.service; - -import java.io.Serializable; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.security.spec.KeySpec; - -import javax.crypto.SecretKey; -import javax.crypto.SecretKeyFactory; -import javax.crypto.spec.PBEKeySpec; - -import jakarta.inject.Singleton; - -import org.apache.commons.lang3.StringUtils; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -// taken from http://www.javacodegeeks.com/2012/05/secure-password-storage-donts-dos-and.html -@SuppressWarnings("serial") -@Slf4j -@RequiredArgsConstructor -@Singleton -public class PasswordEncryptionService implements Serializable { - - public boolean authenticate(String attemptedPassword, byte[] encryptedPassword, byte[] salt) { - if (StringUtils.isBlank(attemptedPassword)) { - return false; - } - // Encrypt the clear-text password using the same salt that was used to - // encrypt the original password - byte[] encryptedAttemptedPassword = null; - try { - encryptedAttemptedPassword = getEncryptedPassword(attemptedPassword, salt); - } catch (Exception e) { - // should never happen - log.error(e.getMessage(), e); - } - - if (encryptedAttemptedPassword == null) { - return false; - } - - // Authentication succeeds if encrypted password that the user entered - // is equal to the stored hash - return MessageDigest.isEqual(encryptedPassword, encryptedAttemptedPassword); - } - - public byte[] getEncryptedPassword(String password, byte[] salt) { - // PBKDF2 with SHA-1 as the hashing algorithm. Note that the NIST - // specifically names SHA-1 as an acceptable hashing algorithm for - // PBKDF2 - String algorithm = "PBKDF2WithHmacSHA1"; - // SHA-1 generates 160 bit hashes, so that's what makes sense here - int derivedKeyLength = 160; - // Pick an iteration count that works for you. The NIST recommends at - // least 1,000 iterations: - // http://csrc.nist.gov/publications/nistpubs/800-132/nist-sp800-132.pdf - // iOS 4.x reportedly uses 10,000: - // http://blog.crackpassword.com/2010/09/smartphone-forensics-cracking-blackberry-backup-passwords/ - int iterations = 20000; - - KeySpec spec = new PBEKeySpec(password.toCharArray(), salt, iterations, derivedKeyLength); - - byte[] bytes = null; - try { - SecretKeyFactory f = SecretKeyFactory.getInstance(algorithm); - SecretKey key = f.generateSecret(spec); - bytes = key.getEncoded(); - } catch (Exception e) { - // should never happen - log.error(e.getMessage(), e); - } - return bytes; - } - - public byte[] generateSalt() { - // VERY important to use SecureRandom instead of just Random - - byte[] salt = null; - try { - SecureRandom random = SecureRandom.getInstance("SHA1PRNG"); - - // Generate a 8 byte (64 bit) salt as recommended by RSA PKCS5 - salt = new byte[8]; - random.nextBytes(salt); - } catch (NoSuchAlgorithmException e) { - // should never happen - log.error(e.getMessage(), e); - } - return salt; - } - -} +package com.commafeed.backend.service; + +import java.io.Serializable; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.spec.KeySpec; + +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; + +import jakarta.inject.Singleton; + +import org.apache.commons.lang3.StringUtils; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +// taken from http://www.javacodegeeks.com/2012/05/secure-password-storage-donts-dos-and.html +@SuppressWarnings("serial") +@Slf4j +@RequiredArgsConstructor +@Singleton +public class PasswordEncryptionService implements Serializable { + + public boolean authenticate(String attemptedPassword, byte[] encryptedPassword, byte[] salt) { + if (StringUtils.isBlank(attemptedPassword)) { + return false; + } + // Encrypt the clear-text password using the same salt that was used to + // encrypt the original password + byte[] encryptedAttemptedPassword = null; + try { + encryptedAttemptedPassword = getEncryptedPassword(attemptedPassword, salt); + } catch (Exception e) { + // should never happen + log.error(e.getMessage(), e); + } + + if (encryptedAttemptedPassword == null) { + return false; + } + + // Authentication succeeds if encrypted password that the user entered + // is equal to the stored hash + return MessageDigest.isEqual(encryptedPassword, encryptedAttemptedPassword); + } + + public byte[] getEncryptedPassword(String password, byte[] salt) { + // PBKDF2 with SHA-1 as the hashing algorithm. Note that the NIST + // specifically names SHA-1 as an acceptable hashing algorithm for + // PBKDF2 + String algorithm = "PBKDF2WithHmacSHA1"; + // SHA-1 generates 160 bit hashes, so that's what makes sense here + int derivedKeyLength = 160; + // Pick an iteration count that works for you. The NIST recommends at + // least 1,000 iterations: + // http://csrc.nist.gov/publications/nistpubs/800-132/nist-sp800-132.pdf + // iOS 4.x reportedly uses 10,000: + // http://blog.crackpassword.com/2010/09/smartphone-forensics-cracking-blackberry-backup-passwords/ + int iterations = 20000; + + KeySpec spec = new PBEKeySpec(password.toCharArray(), salt, iterations, derivedKeyLength); + + byte[] bytes = null; + try { + SecretKeyFactory f = SecretKeyFactory.getInstance(algorithm); + SecretKey key = f.generateSecret(spec); + bytes = key.getEncoded(); + } catch (Exception e) { + // should never happen + log.error(e.getMessage(), e); + } + return bytes; + } + + public byte[] generateSalt() { + // VERY important to use SecureRandom instead of just Random + + byte[] salt = null; + try { + SecureRandom random = SecureRandom.getInstance("SHA1PRNG"); + + // Generate a 8 byte (64 bit) salt as recommended by RSA PKCS5 + salt = new byte[8]; + random.nextBytes(salt); + } catch (NoSuchAlgorithmException e) { + // should never happen + log.error(e.getMessage(), e); + } + return salt; + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/service/UserService.java b/commafeed-server/src/main/java/com/commafeed/backend/service/UserService.java index c73e95f8..992c2001 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/service/UserService.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/service/UserService.java @@ -1,166 +1,166 @@ -package com.commafeed.backend.service; - -import java.time.Instant; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; - -import jakarta.inject.Singleton; - -import org.apache.commons.lang3.StringUtils; - -import com.commafeed.CommaFeedApplication; -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.Digests; -import com.commafeed.backend.dao.FeedCategoryDAO; -import com.commafeed.backend.dao.FeedSubscriptionDAO; -import com.commafeed.backend.dao.UserDAO; -import com.commafeed.backend.dao.UserRoleDAO; -import com.commafeed.backend.dao.UserSettingsDAO; -import com.commafeed.backend.model.User; -import com.commafeed.backend.model.UserRole; -import com.commafeed.backend.model.UserRole.Role; -import com.commafeed.backend.service.internal.PostLoginActivities; -import com.google.common.base.Preconditions; - -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Singleton -public class UserService { - - private final FeedCategoryDAO feedCategoryDAO; - private final FeedSubscriptionDAO feedSubscriptionDAO; - private final UserDAO userDAO; - private final UserRoleDAO userRoleDAO; - private final UserSettingsDAO userSettingsDAO; - - private final PasswordEncryptionService encryptionService; - private final CommaFeedConfiguration config; - - private final PostLoginActivities postLoginActivities; - - /** - * try to log in with given credentials - */ - public Optional login(String nameOrEmail, String password) { - if (nameOrEmail == null || password == null) { - return Optional.empty(); - } - - User user = userDAO.findByName(nameOrEmail); - if (user == null) { - user = userDAO.findByEmail(nameOrEmail); - } - if (user != null && !user.isDisabled()) { - boolean authenticated = encryptionService.authenticate(password, user.getPassword(), user.getSalt()); - if (authenticated) { - performPostLoginActivities(user); - return Optional.of(user); - } - } - return Optional.empty(); - } - - /** - * try to log in with given api key - */ - public Optional login(String apiKey) { - if (apiKey == null) { - return Optional.empty(); - } - - User user = userDAO.findByApiKey(apiKey); - if (user != null && !user.isDisabled()) { - performPostLoginActivities(user); - return Optional.of(user); - } - return Optional.empty(); - } - - /** - * try to log in with given fever api key - */ - public Optional login(long userId, String feverApiKey) { - if (feverApiKey == null) { - return Optional.empty(); - } - - User user = userDAO.findById(userId); - if (user == null || user.isDisabled() || user.getApiKey() == null) { - return Optional.empty(); - } - - String computedFeverApiKey = Digests.md5Hex(user.getName() + ":" + user.getApiKey()); - if (!computedFeverApiKey.equals(feverApiKey)) { - return Optional.empty(); - } - - performPostLoginActivities(user); - return Optional.of(user); - } - - /** - * should triggers after successful login - */ - public void performPostLoginActivities(User user) { - postLoginActivities.executeFor(user); - } - - public User register(String name, String password, String email, Collection roles) { - return register(name, password, email, roles, false); - } - - public User register(String name, String password, String email, Collection roles, boolean forceRegistration) { - - if (!forceRegistration) { - Preconditions.checkState(config.users().allowRegistrations(), "Registrations are closed on this CommaFeed instance"); - } - - Preconditions.checkArgument(userDAO.findByName(name) == null, "Name already taken"); - if (StringUtils.isNotBlank(email)) { - Preconditions.checkArgument(userDAO.findByEmail(email) == null, "Email already taken"); - } - - User user = new User(); - byte[] salt = encryptionService.generateSalt(); - user.setName(name); - user.setEmail(email); - user.setCreated(Instant.now()); - user.setSalt(salt); - user.setPassword(encryptionService.getEncryptedPassword(password, salt)); - userDAO.saveOrUpdate(user); - for (Role role : roles) { - userRoleDAO.saveOrUpdate(new UserRole(user, role)); - } - return user; - } - - public void createAdminUser() { - register(CommaFeedApplication.USERNAME_ADMIN, "admin", "admin@commafeed.com", Arrays.asList(Role.ADMIN, Role.USER), true); - } - - public void createDemoUser() { - register(CommaFeedApplication.USERNAME_DEMO, "demo", "demo@commafeed.com", Collections.singletonList(Role.USER), true); - } - - public void unregister(User user) { - userSettingsDAO.delete(userSettingsDAO.findByUser(user)); - userRoleDAO.delete(userRoleDAO.findAll(user)); - feedSubscriptionDAO.delete(feedSubscriptionDAO.findAll(user)); - feedCategoryDAO.delete(feedCategoryDAO.findAll(user)); - userDAO.delete(user); - } - - public String generateApiKey(User user) { - byte[] key = encryptionService.getEncryptedPassword(UUID.randomUUID().toString(), user.getSalt()); - return Digests.sha1Hex(key); - } - - public Set getRoles(User user) { - return userRoleDAO.findRoles(user); - } -} +package com.commafeed.backend.service; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import jakarta.inject.Singleton; + +import org.apache.commons.lang3.StringUtils; + +import com.commafeed.CommaFeedApplication; +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.backend.Digests; +import com.commafeed.backend.dao.FeedCategoryDAO; +import com.commafeed.backend.dao.FeedSubscriptionDAO; +import com.commafeed.backend.dao.UserDAO; +import com.commafeed.backend.dao.UserRoleDAO; +import com.commafeed.backend.dao.UserSettingsDAO; +import com.commafeed.backend.model.User; +import com.commafeed.backend.model.UserRole; +import com.commafeed.backend.model.UserRole.Role; +import com.commafeed.backend.service.internal.PostLoginActivities; +import com.google.common.base.Preconditions; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Singleton +public class UserService { + + private final FeedCategoryDAO feedCategoryDAO; + private final FeedSubscriptionDAO feedSubscriptionDAO; + private final UserDAO userDAO; + private final UserRoleDAO userRoleDAO; + private final UserSettingsDAO userSettingsDAO; + + private final PasswordEncryptionService encryptionService; + private final CommaFeedConfiguration config; + + private final PostLoginActivities postLoginActivities; + + /** + * try to log in with given credentials + */ + public Optional login(String nameOrEmail, String password) { + if (nameOrEmail == null || password == null) { + return Optional.empty(); + } + + User user = userDAO.findByName(nameOrEmail); + if (user == null) { + user = userDAO.findByEmail(nameOrEmail); + } + if (user != null && !user.isDisabled()) { + boolean authenticated = encryptionService.authenticate(password, user.getPassword(), user.getSalt()); + if (authenticated) { + performPostLoginActivities(user); + return Optional.of(user); + } + } + return Optional.empty(); + } + + /** + * try to log in with given api key + */ + public Optional login(String apiKey) { + if (apiKey == null) { + return Optional.empty(); + } + + User user = userDAO.findByApiKey(apiKey); + if (user != null && !user.isDisabled()) { + performPostLoginActivities(user); + return Optional.of(user); + } + return Optional.empty(); + } + + /** + * try to log in with given fever api key + */ + public Optional login(long userId, String feverApiKey) { + if (feverApiKey == null) { + return Optional.empty(); + } + + User user = userDAO.findById(userId); + if (user == null || user.isDisabled() || user.getApiKey() == null) { + return Optional.empty(); + } + + String computedFeverApiKey = Digests.md5Hex(user.getName() + ":" + user.getApiKey()); + if (!computedFeverApiKey.equals(feverApiKey)) { + return Optional.empty(); + } + + performPostLoginActivities(user); + return Optional.of(user); + } + + /** + * should triggers after successful login + */ + public void performPostLoginActivities(User user) { + postLoginActivities.executeFor(user); + } + + public User register(String name, String password, String email, Collection roles) { + return register(name, password, email, roles, false); + } + + public User register(String name, String password, String email, Collection roles, boolean forceRegistration) { + + if (!forceRegistration) { + Preconditions.checkState(config.users().allowRegistrations(), "Registrations are closed on this CommaFeed instance"); + } + + Preconditions.checkArgument(userDAO.findByName(name) == null, "Name already taken"); + if (StringUtils.isNotBlank(email)) { + Preconditions.checkArgument(userDAO.findByEmail(email) == null, "Email already taken"); + } + + User user = new User(); + byte[] salt = encryptionService.generateSalt(); + user.setName(name); + user.setEmail(email); + user.setCreated(Instant.now()); + user.setSalt(salt); + user.setPassword(encryptionService.getEncryptedPassword(password, salt)); + userDAO.saveOrUpdate(user); + for (Role role : roles) { + userRoleDAO.saveOrUpdate(new UserRole(user, role)); + } + return user; + } + + public void createAdminUser() { + register(CommaFeedApplication.USERNAME_ADMIN, "admin", "admin@commafeed.com", Arrays.asList(Role.ADMIN, Role.USER), true); + } + + public void createDemoUser() { + register(CommaFeedApplication.USERNAME_DEMO, "demo", "demo@commafeed.com", Collections.singletonList(Role.USER), true); + } + + public void unregister(User user) { + userSettingsDAO.delete(userSettingsDAO.findByUser(user)); + userRoleDAO.delete(userRoleDAO.findAll(user)); + feedSubscriptionDAO.delete(feedSubscriptionDAO.findAll(user)); + feedCategoryDAO.delete(feedCategoryDAO.findAll(user)); + userDAO.delete(user); + } + + public String generateApiKey(User user) { + byte[] key = encryptionService.getEncryptedPassword(UUID.randomUUID().toString(), user.getSalt()); + return Digests.sha1Hex(key); + } + + public Set getRoles(User user) { + return userRoleDAO.findRoles(user); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/service/db/DatabaseCleaningService.java b/commafeed-server/src/main/java/com/commafeed/backend/service/db/DatabaseCleaningService.java index 5f2b79b9..db227b48 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/service/db/DatabaseCleaningService.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/service/db/DatabaseCleaningService.java @@ -1,133 +1,133 @@ -package com.commafeed.backend.service.db; - -import java.time.Instant; -import java.util.List; - -import jakarta.inject.Singleton; - -import com.codahale.metrics.Meter; -import com.codahale.metrics.MetricRegistry; -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.dao.FeedDAO; -import com.commafeed.backend.dao.FeedEntryContentDAO; -import com.commafeed.backend.dao.FeedEntryDAO; -import com.commafeed.backend.dao.FeedEntryDAO.FeedCapacity; -import com.commafeed.backend.dao.FeedEntryStatusDAO; -import com.commafeed.backend.dao.UnitOfWork; -import com.commafeed.backend.model.AbstractModel; -import com.commafeed.backend.model.Feed; - -import lombok.extern.slf4j.Slf4j; - -/** - * Contains utility methods for cleaning the database - * - */ -@Slf4j -@Singleton -public class DatabaseCleaningService { - - private final int batchSize; - - private final UnitOfWork unitOfWork; - private final FeedDAO feedDAO; - private final FeedEntryDAO feedEntryDAO; - private final FeedEntryContentDAO feedEntryContentDAO; - private final FeedEntryStatusDAO feedEntryStatusDAO; - private final Meter entriesDeletedMeter; - - public DatabaseCleaningService(CommaFeedConfiguration config, UnitOfWork unitOfWork, FeedDAO feedDAO, FeedEntryDAO feedEntryDAO, - FeedEntryContentDAO feedEntryContentDAO, FeedEntryStatusDAO feedEntryStatusDAO, MetricRegistry metrics) { - this.unitOfWork = unitOfWork; - this.feedDAO = feedDAO; - this.feedEntryDAO = feedEntryDAO; - this.feedEntryContentDAO = feedEntryContentDAO; - this.feedEntryStatusDAO = feedEntryStatusDAO; - this.batchSize = config.database().cleanup().batchSize(); - this.entriesDeletedMeter = metrics.meter(MetricRegistry.name(getClass(), "entriesDeleted")); - } - - public void cleanFeedsWithoutSubscriptions() { - log.info("cleaning feeds without subscriptions"); - long total = 0; - int deleted; - long entriesTotal = 0; - do { - List feeds = unitOfWork.call(() -> feedDAO.findWithoutSubscriptions(1)); - for (Feed feed : feeds) { - long entriesDeleted; - do { - entriesDeleted = unitOfWork.call(() -> feedEntryDAO.delete(feed.getId(), batchSize)); - entriesDeletedMeter.mark(entriesDeleted); - entriesTotal += entriesDeleted; - log.debug("removed {} entries for feeds without subscriptions", entriesTotal); - } while (entriesDeleted > 0); - } - deleted = unitOfWork.call(() -> feedDAO.delete(feedDAO.findByIds(feeds.stream().map(AbstractModel::getId).toList()))); - total += deleted; - log.debug("removed {} feeds without subscriptions", total); - } while (deleted != 0); - log.info("cleanup done: {} feeds without subscriptions deleted", total); - } - - public void cleanContentsWithoutEntries() { - log.info("cleaning contents without entries"); - long total = 0; - long deleted; - do { - deleted = unitOfWork.call(() -> feedEntryContentDAO.deleteWithoutEntries(batchSize)); - total += deleted; - log.debug("removed {} contents without entries", total); - } while (deleted != 0); - log.info("cleanup done: {} contents without entries deleted", total); - } - - public void cleanEntriesForFeedsExceedingCapacity(final int maxFeedCapacity) { - log.info("cleaning entries exceeding feed capacity"); - long total = 0; - while (true) { - List feeds = unitOfWork.call(() -> feedEntryDAO.findFeedsExceedingCapacity(maxFeedCapacity, batchSize)); - if (feeds.isEmpty()) { - break; - } - - for (final FeedCapacity feed : feeds) { - long remaining = feed.getCapacity() - maxFeedCapacity; - do { - final long rem = remaining; - int deleted = unitOfWork.call(() -> feedEntryDAO.deleteOldEntries(feed.getId(), Math.min(batchSize, rem))); - entriesDeletedMeter.mark(deleted); - total += deleted; - remaining -= deleted; - log.debug("removed {} entries for feeds exceeding capacity", total); - } while (remaining > 0); - } - } - log.info("cleanup done: {} entries for feeds exceeding capacity deleted", total); - } - - public void cleanEntriesOlderThan(final Instant olderThan) { - log.info("cleaning old entries"); - long total = 0; - long deleted; - do { - deleted = unitOfWork.call(() -> feedEntryDAO.deleteEntriesOlderThan(olderThan, batchSize)); - entriesDeletedMeter.mark(deleted); - total += deleted; - log.debug("removed {} old entries", total); - } while (deleted != 0); - log.info("cleanup done: {} old entries deleted", total); - } - - public void cleanStatusesOlderThan(final Instant olderThan) { - log.info("cleaning old read statuses"); - long total = 0; - long deleted; - do { - deleted = unitOfWork.call(() -> feedEntryStatusDAO.deleteOldStatuses(olderThan, batchSize)); - total += deleted; - log.debug("removed {} old read statuses", total); - } while (deleted != 0); - log.info("cleanup done: {} old read statuses deleted", total); - } -} +package com.commafeed.backend.service.db; + +import java.time.Instant; +import java.util.List; + +import jakarta.inject.Singleton; + +import com.codahale.metrics.Meter; +import com.codahale.metrics.MetricRegistry; +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.backend.dao.FeedDAO; +import com.commafeed.backend.dao.FeedEntryContentDAO; +import com.commafeed.backend.dao.FeedEntryDAO; +import com.commafeed.backend.dao.FeedEntryDAO.FeedCapacity; +import com.commafeed.backend.dao.FeedEntryStatusDAO; +import com.commafeed.backend.dao.UnitOfWork; +import com.commafeed.backend.model.AbstractModel; +import com.commafeed.backend.model.Feed; + +import lombok.extern.slf4j.Slf4j; + +/** + * Contains utility methods for cleaning the database + * + */ +@Slf4j +@Singleton +public class DatabaseCleaningService { + + private final int batchSize; + + private final UnitOfWork unitOfWork; + private final FeedDAO feedDAO; + private final FeedEntryDAO feedEntryDAO; + private final FeedEntryContentDAO feedEntryContentDAO; + private final FeedEntryStatusDAO feedEntryStatusDAO; + private final Meter entriesDeletedMeter; + + public DatabaseCleaningService(CommaFeedConfiguration config, UnitOfWork unitOfWork, FeedDAO feedDAO, FeedEntryDAO feedEntryDAO, + FeedEntryContentDAO feedEntryContentDAO, FeedEntryStatusDAO feedEntryStatusDAO, MetricRegistry metrics) { + this.unitOfWork = unitOfWork; + this.feedDAO = feedDAO; + this.feedEntryDAO = feedEntryDAO; + this.feedEntryContentDAO = feedEntryContentDAO; + this.feedEntryStatusDAO = feedEntryStatusDAO; + this.batchSize = config.database().cleanup().batchSize(); + this.entriesDeletedMeter = metrics.meter(MetricRegistry.name(getClass(), "entriesDeleted")); + } + + public void cleanFeedsWithoutSubscriptions() { + log.info("cleaning feeds without subscriptions"); + long total = 0; + int deleted; + long entriesTotal = 0; + do { + List feeds = unitOfWork.call(() -> feedDAO.findWithoutSubscriptions(1)); + for (Feed feed : feeds) { + long entriesDeleted; + do { + entriesDeleted = unitOfWork.call(() -> feedEntryDAO.delete(feed.getId(), batchSize)); + entriesDeletedMeter.mark(entriesDeleted); + entriesTotal += entriesDeleted; + log.debug("removed {} entries for feeds without subscriptions", entriesTotal); + } while (entriesDeleted > 0); + } + deleted = unitOfWork.call(() -> feedDAO.delete(feedDAO.findByIds(feeds.stream().map(AbstractModel::getId).toList()))); + total += deleted; + log.debug("removed {} feeds without subscriptions", total); + } while (deleted != 0); + log.info("cleanup done: {} feeds without subscriptions deleted", total); + } + + public void cleanContentsWithoutEntries() { + log.info("cleaning contents without entries"); + long total = 0; + long deleted; + do { + deleted = unitOfWork.call(() -> feedEntryContentDAO.deleteWithoutEntries(batchSize)); + total += deleted; + log.debug("removed {} contents without entries", total); + } while (deleted != 0); + log.info("cleanup done: {} contents without entries deleted", total); + } + + public void cleanEntriesForFeedsExceedingCapacity(final int maxFeedCapacity) { + log.info("cleaning entries exceeding feed capacity"); + long total = 0; + while (true) { + List feeds = unitOfWork.call(() -> feedEntryDAO.findFeedsExceedingCapacity(maxFeedCapacity, batchSize)); + if (feeds.isEmpty()) { + break; + } + + for (final FeedCapacity feed : feeds) { + long remaining = feed.getCapacity() - maxFeedCapacity; + do { + final long rem = remaining; + int deleted = unitOfWork.call(() -> feedEntryDAO.deleteOldEntries(feed.getId(), Math.min(batchSize, rem))); + entriesDeletedMeter.mark(deleted); + total += deleted; + remaining -= deleted; + log.debug("removed {} entries for feeds exceeding capacity", total); + } while (remaining > 0); + } + } + log.info("cleanup done: {} entries for feeds exceeding capacity deleted", total); + } + + public void cleanEntriesOlderThan(final Instant olderThan) { + log.info("cleaning old entries"); + long total = 0; + long deleted; + do { + deleted = unitOfWork.call(() -> feedEntryDAO.deleteEntriesOlderThan(olderThan, batchSize)); + entriesDeletedMeter.mark(deleted); + total += deleted; + log.debug("removed {} old entries", total); + } while (deleted != 0); + log.info("cleanup done: {} old entries deleted", total); + } + + public void cleanStatusesOlderThan(final Instant olderThan) { + log.info("cleaning old read statuses"); + long total = 0; + long deleted; + do { + deleted = unitOfWork.call(() -> feedEntryStatusDAO.deleteOldStatuses(olderThan, batchSize)); + total += deleted; + log.debug("removed {} old read statuses", total); + } while (deleted != 0); + log.info("cleanup done: {} old read statuses deleted", total); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/service/db/DatabaseStartupService.java b/commafeed-server/src/main/java/com/commafeed/backend/service/db/DatabaseStartupService.java index 674858e1..8f363ab7 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/service/db/DatabaseStartupService.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/service/db/DatabaseStartupService.java @@ -1,63 +1,63 @@ -package com.commafeed.backend.service.db; - -import jakarta.inject.Singleton; - -import org.kohsuke.MetaInfServices; - -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.dao.UnitOfWork; -import com.commafeed.backend.dao.UserDAO; -import com.commafeed.backend.service.UserService; - -import liquibase.database.Database; -import liquibase.database.core.PostgresDatabase; -import liquibase.structure.DatabaseObject; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@RequiredArgsConstructor -@Singleton -public class DatabaseStartupService { - - private final UnitOfWork unitOfWork; - private final UserDAO userDAO; - private final UserService userService; - private final CommaFeedConfiguration config; - - public void populateInitialData() { - long count = unitOfWork.call(userDAO::count); - if (count == 0) { - unitOfWork.run(this::initialData); - } - } - - private void initialData() { - log.info("populating database with default values"); - try { - userService.createAdminUser(); - if (config.users().createDemoAccount()) { - userService.createDemoUser(); - } - } catch (Exception 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 - */ - @MetaInfServices(Database.class) - public static class LowerCaseColumnsPostgresDatabase extends PostgresDatabase { - @Override - public String escapeObjectName(String objectName, Class objectType) { - return objectName; - } - - @Override - public int getPriority() { - return super.getPriority() + 1; - } - } - -} +package com.commafeed.backend.service.db; + +import jakarta.inject.Singleton; + +import org.kohsuke.MetaInfServices; + +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.backend.dao.UnitOfWork; +import com.commafeed.backend.dao.UserDAO; +import com.commafeed.backend.service.UserService; + +import liquibase.database.Database; +import liquibase.database.core.PostgresDatabase; +import liquibase.structure.DatabaseObject; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Singleton +public class DatabaseStartupService { + + private final UnitOfWork unitOfWork; + private final UserDAO userDAO; + private final UserService userService; + private final CommaFeedConfiguration config; + + public void populateInitialData() { + long count = unitOfWork.call(userDAO::count); + if (count == 0) { + unitOfWork.run(this::initialData); + } + } + + private void initialData() { + log.info("populating database with default values"); + try { + userService.createAdminUser(); + if (config.users().createDemoAccount()) { + userService.createDemoUser(); + } + } catch (Exception 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 + */ + @MetaInfServices(Database.class) + public static class LowerCaseColumnsPostgresDatabase extends PostgresDatabase { + @Override + public String escapeObjectName(String objectName, Class objectType) { + return objectName; + } + + @Override + public int getPriority() { + return super.getPriority() + 1; + } + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/service/internal/PostLoginActivities.java b/commafeed-server/src/main/java/com/commafeed/backend/service/internal/PostLoginActivities.java index 97cd577e..36184167 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/service/internal/PostLoginActivities.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/service/internal/PostLoginActivities.java @@ -1,30 +1,30 @@ -package com.commafeed.backend.service.internal; - -import java.time.Instant; -import java.time.temporal.ChronoUnit; - -import jakarta.inject.Singleton; - -import com.commafeed.backend.dao.UnitOfWork; -import com.commafeed.backend.dao.UserDAO; -import com.commafeed.backend.model.User; - -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Singleton -public class PostLoginActivities { - - private final UserDAO userDAO; - private final UnitOfWork unitOfWork; - - 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 - Instant now = Instant.now(); - Instant lastLogin = user.getLastLogin(); - if (lastLogin == null || ChronoUnit.MINUTES.between(lastLogin, now) >= 30) { - user.setLastLogin(now); - unitOfWork.run(() -> userDAO.saveOrUpdate(user)); - } - } -} +package com.commafeed.backend.service.internal; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +import jakarta.inject.Singleton; + +import com.commafeed.backend.dao.UnitOfWork; +import com.commafeed.backend.dao.UserDAO; +import com.commafeed.backend.model.User; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Singleton +public class PostLoginActivities { + + private final UserDAO userDAO; + private final UnitOfWork unitOfWork; + + 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 + Instant now = Instant.now(); + Instant lastLogin = user.getLastLogin(); + if (lastLogin == null || ChronoUnit.MINUTES.between(lastLogin, now) >= 30) { + user.setLastLogin(now); + unitOfWork.run(() -> userDAO.saveOrUpdate(user)); + } + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/task/DemoAccountCleanupTask.java b/commafeed-server/src/main/java/com/commafeed/backend/task/DemoAccountCleanupTask.java index 08154587..4ad08476 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/task/DemoAccountCleanupTask.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/task/DemoAccountCleanupTask.java @@ -1,61 +1,61 @@ -package com.commafeed.backend.task; - -import java.util.concurrent.TimeUnit; - -import jakarta.inject.Singleton; - -import com.commafeed.CommaFeedApplication; -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.dao.UnitOfWork; -import com.commafeed.backend.dao.UserDAO; -import com.commafeed.backend.model.User; -import com.commafeed.backend.service.UserService; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@RequiredArgsConstructor -@Singleton -@Slf4j -public class DemoAccountCleanupTask extends ScheduledTask { - - private final CommaFeedConfiguration config; - private final UnitOfWork unitOfWork; - private final UserDAO userDAO; - private final UserService userService; - - @Override - protected void run() { - if (!config.users().createDemoAccount()) { - return; - } - - log.info("recreating demo user account"); - unitOfWork.run(() -> { - User demoUser = userDAO.findByName(CommaFeedApplication.USERNAME_DEMO); - if (demoUser == null) { - return; - } - - userService.unregister(demoUser); - userService.createDemoUser(); - }); - - } - - @Override - protected long getInitialDelay() { - return 1; - } - - @Override - protected long getPeriod() { - return getTimeUnit().convert(24, TimeUnit.HOURS); - } - - @Override - protected TimeUnit getTimeUnit() { - return TimeUnit.MINUTES; - } - -} +package com.commafeed.backend.task; + +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Singleton; + +import com.commafeed.CommaFeedApplication; +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.backend.dao.UnitOfWork; +import com.commafeed.backend.dao.UserDAO; +import com.commafeed.backend.model.User; +import com.commafeed.backend.service.UserService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Singleton +@Slf4j +public class DemoAccountCleanupTask extends ScheduledTask { + + private final CommaFeedConfiguration config; + private final UnitOfWork unitOfWork; + private final UserDAO userDAO; + private final UserService userService; + + @Override + protected void run() { + if (!config.users().createDemoAccount()) { + return; + } + + log.info("recreating demo user account"); + unitOfWork.run(() -> { + User demoUser = userDAO.findByName(CommaFeedApplication.USERNAME_DEMO); + if (demoUser == null) { + return; + } + + userService.unregister(demoUser); + userService.createDemoUser(); + }); + + } + + @Override + protected long getInitialDelay() { + return 1; + } + + @Override + protected long getPeriod() { + return getTimeUnit().convert(24, TimeUnit.HOURS); + } + + @Override + protected TimeUnit getTimeUnit() { + return TimeUnit.MINUTES; + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/task/EntriesExceedingFeedCapacityCleanupTask.java b/commafeed-server/src/main/java/com/commafeed/backend/task/EntriesExceedingFeedCapacityCleanupTask.java index 9c8f9061..0291f6fc 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/task/EntriesExceedingFeedCapacityCleanupTask.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/task/EntriesExceedingFeedCapacityCleanupTask.java @@ -1,42 +1,42 @@ -package com.commafeed.backend.task; - -import java.util.concurrent.TimeUnit; - -import jakarta.inject.Singleton; - -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.service.db.DatabaseCleaningService; - -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Singleton -public class EntriesExceedingFeedCapacityCleanupTask extends ScheduledTask { - - private final CommaFeedConfiguration config; - private final DatabaseCleaningService cleaner; - - @Override - public void run() { - int maxFeedCapacity = config.database().cleanup().maxFeedCapacity(); - if (maxFeedCapacity > 0) { - cleaner.cleanEntriesForFeedsExceedingCapacity(maxFeedCapacity); - } - } - - @Override - public long getInitialDelay() { - return 10; - } - - @Override - public long getPeriod() { - return 60; - } - - @Override - public TimeUnit getTimeUnit() { - return TimeUnit.MINUTES; - } - -} +package com.commafeed.backend.task; + +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Singleton; + +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.backend.service.db.DatabaseCleaningService; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Singleton +public class EntriesExceedingFeedCapacityCleanupTask extends ScheduledTask { + + private final CommaFeedConfiguration config; + private final DatabaseCleaningService cleaner; + + @Override + public void run() { + int maxFeedCapacity = config.database().cleanup().maxFeedCapacity(); + if (maxFeedCapacity > 0) { + cleaner.cleanEntriesForFeedsExceedingCapacity(maxFeedCapacity); + } + } + + @Override + public long getInitialDelay() { + return 10; + } + + @Override + public long getPeriod() { + return 60; + } + + @Override + public TimeUnit getTimeUnit() { + return TimeUnit.MINUTES; + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/task/OldEntriesCleanupTask.java b/commafeed-server/src/main/java/com/commafeed/backend/task/OldEntriesCleanupTask.java index ed11fe01..d89545fb 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/task/OldEntriesCleanupTask.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/task/OldEntriesCleanupTask.java @@ -1,45 +1,45 @@ -package com.commafeed.backend.task; - -import java.time.Duration; -import java.time.Instant; -import java.util.concurrent.TimeUnit; - -import jakarta.inject.Singleton; - -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.service.db.DatabaseCleaningService; - -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Singleton -public class OldEntriesCleanupTask extends ScheduledTask { - - private final CommaFeedConfiguration config; - private final DatabaseCleaningService cleaner; - - @Override - public void run() { - Duration entriesMaxAge = config.database().cleanup().entriesMaxAge(); - if (!entriesMaxAge.isZero()) { - Instant threshold = Instant.now().minus(entriesMaxAge); - cleaner.cleanEntriesOlderThan(threshold); - } - } - - @Override - public long getInitialDelay() { - return 5; - } - - @Override - public long getPeriod() { - return 60; - } - - @Override - public TimeUnit getTimeUnit() { - return TimeUnit.MINUTES; - } - -} +package com.commafeed.backend.task; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Singleton; + +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.backend.service.db.DatabaseCleaningService; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Singleton +public class OldEntriesCleanupTask extends ScheduledTask { + + private final CommaFeedConfiguration config; + private final DatabaseCleaningService cleaner; + + @Override + public void run() { + Duration entriesMaxAge = config.database().cleanup().entriesMaxAge(); + if (!entriesMaxAge.isZero()) { + Instant threshold = Instant.now().minus(entriesMaxAge); + cleaner.cleanEntriesOlderThan(threshold); + } + } + + @Override + public long getInitialDelay() { + return 5; + } + + @Override + public long getPeriod() { + return 60; + } + + @Override + public TimeUnit getTimeUnit() { + return TimeUnit.MINUTES; + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/task/OldStatusesCleanupTask.java b/commafeed-server/src/main/java/com/commafeed/backend/task/OldStatusesCleanupTask.java index f996c7b6..d1776e40 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/task/OldStatusesCleanupTask.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/task/OldStatusesCleanupTask.java @@ -1,43 +1,43 @@ -package com.commafeed.backend.task; - -import java.time.Instant; -import java.util.concurrent.TimeUnit; - -import jakarta.inject.Singleton; - -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.service.db.DatabaseCleaningService; - -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Singleton -public class OldStatusesCleanupTask extends ScheduledTask { - - private final CommaFeedConfiguration config; - private final DatabaseCleaningService cleaner; - - @Override - public void run() { - Instant threshold = config.database().cleanup().statusesInstantThreshold(); - if (threshold != null) { - cleaner.cleanStatusesOlderThan(threshold); - } - } - - @Override - public long getInitialDelay() { - return 15; - } - - @Override - public long getPeriod() { - return 60; - } - - @Override - public TimeUnit getTimeUnit() { - return TimeUnit.MINUTES; - } - -} +package com.commafeed.backend.task; + +import java.time.Instant; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Singleton; + +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.backend.service.db.DatabaseCleaningService; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Singleton +public class OldStatusesCleanupTask extends ScheduledTask { + + private final CommaFeedConfiguration config; + private final DatabaseCleaningService cleaner; + + @Override + public void run() { + Instant threshold = config.database().cleanup().statusesInstantThreshold(); + if (threshold != null) { + cleaner.cleanStatusesOlderThan(threshold); + } + } + + @Override + public long getInitialDelay() { + return 15; + } + + @Override + public long getPeriod() { + return 60; + } + + @Override + public TimeUnit getTimeUnit() { + return TimeUnit.MINUTES; + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedContentsCleanupTask.java b/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedContentsCleanupTask.java index ee8f73b8..9b9ee5fc 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedContentsCleanupTask.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedContentsCleanupTask.java @@ -1,37 +1,37 @@ -package com.commafeed.backend.task; - -import java.util.concurrent.TimeUnit; - -import jakarta.inject.Singleton; - -import com.commafeed.backend.service.db.DatabaseCleaningService; - -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Singleton -public class OrphanedContentsCleanupTask extends ScheduledTask { - - private final DatabaseCleaningService cleaner; - - @Override - public void run() { - cleaner.cleanContentsWithoutEntries(); - } - - @Override - public long getInitialDelay() { - return 25; - } - - @Override - public long getPeriod() { - return 60; - } - - @Override - public TimeUnit getTimeUnit() { - return TimeUnit.MINUTES; - } - -} +package com.commafeed.backend.task; + +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Singleton; + +import com.commafeed.backend.service.db.DatabaseCleaningService; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Singleton +public class OrphanedContentsCleanupTask extends ScheduledTask { + + private final DatabaseCleaningService cleaner; + + @Override + public void run() { + cleaner.cleanContentsWithoutEntries(); + } + + @Override + public long getInitialDelay() { + return 25; + } + + @Override + public long getPeriod() { + return 60; + } + + @Override + public TimeUnit getTimeUnit() { + return TimeUnit.MINUTES; + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedFeedsCleanupTask.java b/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedFeedsCleanupTask.java index 6df699ec..d714fcb9 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedFeedsCleanupTask.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/task/OrphanedFeedsCleanupTask.java @@ -1,37 +1,37 @@ -package com.commafeed.backend.task; - -import java.util.concurrent.TimeUnit; - -import jakarta.inject.Singleton; - -import com.commafeed.backend.service.db.DatabaseCleaningService; - -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Singleton -public class OrphanedFeedsCleanupTask extends ScheduledTask { - - private final DatabaseCleaningService cleaner; - - @Override - public void run() { - cleaner.cleanFeedsWithoutSubscriptions(); - } - - @Override - public long getInitialDelay() { - return 20; - } - - @Override - public long getPeriod() { - return 60; - } - - @Override - public TimeUnit getTimeUnit() { - return TimeUnit.MINUTES; - } - -} +package com.commafeed.backend.task; + +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Singleton; + +import com.commafeed.backend.service.db.DatabaseCleaningService; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Singleton +public class OrphanedFeedsCleanupTask extends ScheduledTask { + + private final DatabaseCleaningService cleaner; + + @Override + public void run() { + cleaner.cleanFeedsWithoutSubscriptions(); + } + + @Override + public long getInitialDelay() { + return 20; + } + + @Override + public long getPeriod() { + return 60; + } + + @Override + public TimeUnit getTimeUnit() { + return TimeUnit.MINUTES; + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/task/ScheduledTask.java b/commafeed-server/src/main/java/com/commafeed/backend/task/ScheduledTask.java index 2531be3c..b9fbdeb6 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/task/ScheduledTask.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/task/ScheduledTask.java @@ -1,30 +1,30 @@ -package com.commafeed.backend.task; - -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -import lombok.extern.slf4j.Slf4j; - -@Slf4j -public abstract class ScheduledTask { - protected abstract void run(); - - protected abstract long getInitialDelay(); - - protected abstract long getPeriod(); - - protected abstract TimeUnit getTimeUnit(); - - public void register(ScheduledExecutorService executor) { - Runnable runnable = () -> { - try { - ScheduledTask.this.run(); - } catch (Exception e) { - log.error(e.getMessage(), e); - } - }; - log.info("registering task {} for execution every {} {}, starting in {} {}", getClass().getSimpleName(), getPeriod(), getTimeUnit(), - getInitialDelay(), getTimeUnit()); - executor.scheduleWithFixedDelay(runnable, getInitialDelay(), getPeriod(), getTimeUnit()); - } -} +package com.commafeed.backend.task; + +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public abstract class ScheduledTask { + protected abstract void run(); + + protected abstract long getInitialDelay(); + + protected abstract long getPeriod(); + + protected abstract TimeUnit getTimeUnit(); + + public void register(ScheduledExecutorService executor) { + Runnable runnable = () -> { + try { + ScheduledTask.this.run(); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + }; + log.info("registering task {} for execution every {} {}, starting in {} {}", getClass().getSimpleName(), getPeriod(), getTimeUnit(), + getInitialDelay(), getTimeUnit()); + executor.scheduleWithFixedDelay(runnable, getInitialDelay(), getPeriod(), getTimeUnit()); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/task/TaskScheduler.java b/commafeed-server/src/main/java/com/commafeed/backend/task/TaskScheduler.java index 5a536d8b..aad6d2f6 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/task/TaskScheduler.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/task/TaskScheduler.java @@ -1,29 +1,29 @@ -package com.commafeed.backend.task; - -import java.util.List; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; - -import jakarta.inject.Singleton; - -import io.quarkus.arc.All; - -@Singleton -public class TaskScheduler { - - private final List tasks; - private final ScheduledExecutorService executor; - - public TaskScheduler(@All List tasks) { - this.tasks = tasks; - this.executor = Executors.newScheduledThreadPool(tasks.size()); - } - - public void start() { - tasks.forEach(task -> task.register(executor)); - } - - public void stop() { - executor.shutdownNow(); - } -} +package com.commafeed.backend.task; + +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +import jakarta.inject.Singleton; + +import io.quarkus.arc.All; + +@Singleton +public class TaskScheduler { + + private final List tasks; + private final ScheduledExecutorService executor; + + public TaskScheduler(@All List tasks) { + this.tasks = tasks; + this.executor = Executors.newScheduledThreadPool(tasks.size()); + } + + public void start() { + tasks.forEach(task -> task.register(executor)); + } + + public void stop() { + executor.shutdownNow(); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/urlprovider/FeedURLProvider.java b/commafeed-server/src/main/java/com/commafeed/backend/urlprovider/FeedURLProvider.java index 34b137f1..91cf9c4c 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/urlprovider/FeedURLProvider.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/urlprovider/FeedURLProvider.java @@ -1,10 +1,10 @@ -package com.commafeed.backend.urlprovider; - -/** - * Tries to find a feed url given the url and page content - */ -public interface FeedURLProvider { - - String get(String url, String urlContent); - -} +package com.commafeed.backend.urlprovider; + +/** + * Tries to find a feed url given the url and page content + */ +public interface FeedURLProvider { + + String get(String url, String urlContent); + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/urlprovider/InPageReferenceFeedURLProvider.java b/commafeed-server/src/main/java/com/commafeed/backend/urlprovider/InPageReferenceFeedURLProvider.java index f9ece22d..b7102f87 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/urlprovider/InPageReferenceFeedURLProvider.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/urlprovider/InPageReferenceFeedURLProvider.java @@ -1,31 +1,31 @@ -package com.commafeed.backend.urlprovider; - -import jakarta.inject.Singleton; - -import org.jsoup.Jsoup; -import org.jsoup.nodes.Document; -import org.jsoup.select.Elements; - -@Singleton -public class InPageReferenceFeedURLProvider implements FeedURLProvider { - - @Override - public String get(String url, String urlContent) { - String foundUrl = null; - - Document doc = Jsoup.parse(urlContent, url); - String root = doc.children().get(0).tagName(); - if ("html".equals(root)) { - Elements atom = doc.select("link[type=application/atom+xml]"); - Elements rss = doc.select("link[type=application/rss+xml]"); - if (!atom.isEmpty()) { - foundUrl = atom.get(0).attr("abs:href"); - } else if (!rss.isEmpty()) { - foundUrl = rss.get(0).attr("abs:href"); - } - } - - return foundUrl; - } - -} +package com.commafeed.backend.urlprovider; + +import jakarta.inject.Singleton; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.select.Elements; + +@Singleton +public class InPageReferenceFeedURLProvider implements FeedURLProvider { + + @Override + public String get(String url, String urlContent) { + String foundUrl = null; + + Document doc = Jsoup.parse(urlContent, url); + String root = doc.children().get(0).tagName(); + if ("html".equals(root)) { + Elements atom = doc.select("link[type=application/atom+xml]"); + Elements rss = doc.select("link[type=application/rss+xml]"); + if (!atom.isEmpty()) { + foundUrl = atom.get(0).attr("abs:href"); + } else if (!rss.isEmpty()) { + foundUrl = rss.get(0).attr("abs:href"); + } + } + + return foundUrl; + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/urlprovider/YoutubeFeedURLProvider.java b/commafeed-server/src/main/java/com/commafeed/backend/urlprovider/YoutubeFeedURLProvider.java index 7c6c38ec..cc7c5a4e 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/urlprovider/YoutubeFeedURLProvider.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/urlprovider/YoutubeFeedURLProvider.java @@ -1,28 +1,28 @@ -package com.commafeed.backend.urlprovider; - -import jakarta.inject.Singleton; - -import org.apache.commons.lang3.StringUtils; - -/** - * Workaround for Youtube channels - * - * converts the channel URL https://www.youtube.com/channel/CHANNEL_ID to the valid feed URL - * https://www.youtube.com/feeds/videos.xml?channel_id=CHANNEL_ID - */ -@Singleton -public class YoutubeFeedURLProvider implements FeedURLProvider { - - private static final String PREFIX = "https://www.youtube.com/channel/"; - private static final String REPLACEMENT_PREFIX = "https://www.youtube.com/feeds/videos.xml?channel_id="; - - @Override - public String get(String url, String urlContent) { - if (!StringUtils.startsWithIgnoreCase(url, PREFIX)) { - return null; - } - - return REPLACEMENT_PREFIX + url.substring(PREFIX.length()); - } - -} +package com.commafeed.backend.urlprovider; + +import jakarta.inject.Singleton; + +import org.apache.commons.lang3.StringUtils; + +/** + * Workaround for Youtube channels + * + * converts the channel URL https://www.youtube.com/channel/CHANNEL_ID to the valid feed URL + * https://www.youtube.com/feeds/videos.xml?channel_id=CHANNEL_ID + */ +@Singleton +public class YoutubeFeedURLProvider implements FeedURLProvider { + + private static final String PREFIX = "https://www.youtube.com/channel/"; + private static final String REPLACEMENT_PREFIX = "https://www.youtube.com/feeds/videos.xml?channel_id="; + + @Override + public String get(String url, String urlContent) { + if (!StringUtils.startsWithIgnoreCase(url, PREFIX)) { + return null; + } + + return REPLACEMENT_PREFIX + url.substring(PREFIX.length()); + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/Category.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/Category.java index 631e56a7..63a0604f 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/Category.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/Category.java @@ -1,41 +1,41 @@ -package com.commafeed.frontend.model; - -import java.io.Serializable; -import java.util.ArrayList; -import java.util.List; - -import io.quarkus.runtime.annotations.RegisterForReflection; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema(description = "Entry details") -@Data -@RegisterForReflection -public class Category implements Serializable { - - @Schema(description = "category id", requiredMode = RequiredMode.REQUIRED) - private String id; - - @Schema(description = "parent category id") - private String parentId; - - @Schema(description = "parent category name") - private String parentName; - - @Schema(description = "category id", requiredMode = RequiredMode.REQUIRED) - private String name; - - @Schema(description = "category children categories", requiredMode = RequiredMode.REQUIRED) - private List children = new ArrayList<>(); - - @Schema(description = "category feeds", requiredMode = RequiredMode.REQUIRED) - private List feeds = new ArrayList<>(); - - @Schema(description = "whether the category is expanded or collapsed", requiredMode = RequiredMode.REQUIRED) - private boolean expanded; - - @Schema(description = "position of the category in the list", requiredMode = RequiredMode.REQUIRED) - private int position; +package com.commafeed.frontend.model; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema(description = "Entry details") +@Data +@RegisterForReflection +public class Category implements Serializable { + + @Schema(description = "category id", requiredMode = RequiredMode.REQUIRED) + private String id; + + @Schema(description = "parent category id") + private String parentId; + + @Schema(description = "parent category name") + private String parentName; + + @Schema(description = "category id", requiredMode = RequiredMode.REQUIRED) + private String name; + + @Schema(description = "category children categories", requiredMode = RequiredMode.REQUIRED) + private List children = new ArrayList<>(); + + @Schema(description = "category feeds", requiredMode = RequiredMode.REQUIRED) + private List feeds = new ArrayList<>(); + + @Schema(description = "whether the category is expanded or collapsed", requiredMode = RequiredMode.REQUIRED) + private boolean expanded; + + @Schema(description = "position of the category in the list", requiredMode = RequiredMode.REQUIRED) + private int position; } \ No newline at end of file diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/Entries.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/Entries.java index 9d51ee62..dc61b4cf 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/Entries.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/Entries.java @@ -1,50 +1,50 @@ -package com.commafeed.frontend.model; - -import java.io.Serializable; -import java.util.ArrayList; -import java.util.List; - -import io.quarkus.runtime.annotations.RegisterForReflection; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema(description = "List of entries with some metadata") -@Data -@RegisterForReflection -public class Entries implements Serializable { - - @Schema(description = "name of the feed or the category requested", requiredMode = RequiredMode.REQUIRED) - private String name; - - @Schema(description = "error or warning message") - private String message; - - @Schema(description = "times the server tried to refresh the feed and failed", requiredMode = RequiredMode.REQUIRED) - private int errorCount; - - @Schema(description = "URL of the website, extracted from the feed, only filled if querying for feed entries, not category entries") - private String feedLink; - - @Schema(description = "list generation timestamp", requiredMode = RequiredMode.REQUIRED) - private long timestamp; - - @Schema(description = "if the query has more elements", requiredMode = RequiredMode.REQUIRED) - private boolean hasMore; - - @Schema(description = "the requested offset") - private int offset; - - @Schema(description = "the requested limit") - private int limit; - - @Schema(description = "list of entries", requiredMode = RequiredMode.REQUIRED) - private List entries = new ArrayList<>(); - - @Schema( - description = "if true, the unread flag was ignored in the request, all entries are returned regardless of their read status", - requiredMode = RequiredMode.REQUIRED) - private boolean ignoredReadStatus; - -} +package com.commafeed.frontend.model; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema(description = "List of entries with some metadata") +@Data +@RegisterForReflection +public class Entries implements Serializable { + + @Schema(description = "name of the feed or the category requested", requiredMode = RequiredMode.REQUIRED) + private String name; + + @Schema(description = "error or warning message") + private String message; + + @Schema(description = "times the server tried to refresh the feed and failed", requiredMode = RequiredMode.REQUIRED) + private int errorCount; + + @Schema(description = "URL of the website, extracted from the feed, only filled if querying for feed entries, not category entries") + private String feedLink; + + @Schema(description = "list generation timestamp", requiredMode = RequiredMode.REQUIRED) + private long timestamp; + + @Schema(description = "if the query has more elements", requiredMode = RequiredMode.REQUIRED) + private boolean hasMore; + + @Schema(description = "the requested offset") + private int offset; + + @Schema(description = "the requested limit") + private int limit; + + @Schema(description = "list of entries", requiredMode = RequiredMode.REQUIRED) + private List entries = new ArrayList<>(); + + @Schema( + description = "if true, the unread flag was ignored in the request, all entries are returned regardless of their read status", + requiredMode = RequiredMode.REQUIRED) + private boolean ignoredReadStatus; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/Entry.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/Entry.java index 9fdde296..33762911 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/Entry.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/Entry.java @@ -1,174 +1,174 @@ -package com.commafeed.frontend.model; - -import java.io.Serializable; -import java.time.Instant; -import java.util.Collections; -import java.util.Date; -import java.util.List; - -import org.apache.commons.lang3.StringUtils; - -import com.commafeed.backend.feed.FeedUtils; -import com.commafeed.backend.model.FeedEntry; -import com.commafeed.backend.model.FeedEntryContent; -import com.commafeed.backend.model.FeedEntryStatus; -import com.commafeed.backend.model.FeedEntryTag; -import com.commafeed.backend.model.FeedSubscription; -import com.rometools.rome.feed.synd.SyndContentImpl; -import com.rometools.rome.feed.synd.SyndEnclosureImpl; -import com.rometools.rome.feed.synd.SyndEntry; -import com.rometools.rome.feed.synd.SyndEntryImpl; - -import io.quarkus.runtime.annotations.RegisterForReflection; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema(description = "Entry details") -@Data -@RegisterForReflection -public class Entry implements Serializable { - - @Schema(description = "entry id", requiredMode = RequiredMode.REQUIRED) - private String id; - - @Schema(description = "entry guid", requiredMode = RequiredMode.REQUIRED) - private String guid; - - @Schema(description = "entry title", requiredMode = RequiredMode.REQUIRED) - private String title; - - @Schema(description = "entry content", requiredMode = RequiredMode.REQUIRED) - private String content; - - @Schema(description = "comma-separated list of categories") - private String categories; - - @Schema(description = "whether entry content and title are rtl", requiredMode = RequiredMode.REQUIRED) - private boolean rtl; - - @Schema(description = "entry author") - private String author; - - @Schema(description = "entry enclosure url, if any") - private String enclosureUrl; - - @Schema(description = "entry enclosure mime type, if any") - private String enclosureType; - - @Schema(description = "entry media description, if any") - private String mediaDescription; - - @Schema(description = "entry media thumbnail url, if any") - private String mediaThumbnailUrl; - - @Schema(description = "entry media thumbnail width, if any") - private Integer mediaThumbnailWidth; - - @Schema(description = "entry media thumbnail height, if any") - private Integer mediaThumbnailHeight; - - @Schema(description = "entry publication date", type = "number", requiredMode = RequiredMode.REQUIRED) - private Instant date; - - @Schema(description = "entry insertion date in the database", type = "number", requiredMode = RequiredMode.REQUIRED) - private Instant insertedDate; - - @Schema(description = "feed id", requiredMode = RequiredMode.REQUIRED) - private String feedId; - - @Schema(description = "feed name", requiredMode = RequiredMode.REQUIRED) - private String feedName; - - @Schema(description = "this entry's feed url", requiredMode = RequiredMode.REQUIRED) - private String feedUrl; - - @Schema(description = "this entry's website url", requiredMode = RequiredMode.REQUIRED) - private String feedLink; - - @Schema(description = "The favicon url to use for this feed", requiredMode = RequiredMode.REQUIRED) - private String iconUrl; - - @Schema(description = "entry url", requiredMode = RequiredMode.REQUIRED) - private String url; - - @Schema(description = "read status", requiredMode = RequiredMode.REQUIRED) - private boolean read; - - @Schema(description = "starred status", requiredMode = RequiredMode.REQUIRED) - private boolean starred; - - @Schema(description = "whether the entry is still markable (old entry statuses are discarded)", requiredMode = RequiredMode.REQUIRED) - private boolean markable; - - @Schema(description = "tags", requiredMode = RequiredMode.REQUIRED) - private List tags; - - public static Entry build(FeedEntryStatus status, boolean proxyImages) { - Entry entry = new Entry(); - - FeedEntry feedEntry = status.getEntry(); - FeedSubscription sub = status.getSubscription(); - FeedEntryContent content = feedEntry.getContent(); - - entry.setId(String.valueOf(feedEntry.getId())); - entry.setGuid(feedEntry.getGuid()); - entry.setRead(status.isRead()); - entry.setStarred(status.isStarred()); - entry.setMarkable(status.isMarkable()); - entry.setDate(feedEntry.getPublished()); - entry.setInsertedDate(feedEntry.getInserted()); - entry.setUrl(feedEntry.getUrl()); - entry.setFeedName(sub.getTitle()); - entry.setFeedId(String.valueOf(sub.getId())); - entry.setFeedUrl(sub.getFeed().getUrl()); - entry.setFeedLink(sub.getFeed().getLink()); - entry.setIconUrl(FeedUtils.getFaviconUrl(sub)); - entry.setTags(status.getTags().stream().map(FeedEntryTag::getName).toList()); - - if (content != null) { - entry.setRtl(content.isRTL()); - entry.setTitle(content.getTitle()); - entry.setContent(proxyImages ? FeedUtils.proxyImages(content.getContent()) : content.getContent()); - entry.setAuthor(content.getAuthor()); - - entry.setEnclosureType(content.getEnclosureType()); - entry.setEnclosureUrl(proxyImages && StringUtils.contains(content.getEnclosureType(), "image") - ? FeedUtils.proxyImage(content.getEnclosureUrl()) - : content.getEnclosureUrl()); - - entry.setMediaDescription(content.getMediaDescription()); - entry.setMediaThumbnailUrl(proxyImages ? FeedUtils.proxyImage(content.getMediaThumbnailUrl()) : content.getMediaThumbnailUrl()); - entry.setMediaThumbnailWidth(content.getMediaThumbnailWidth()); - entry.setMediaThumbnailHeight(content.getMediaThumbnailHeight()); - - entry.setCategories(content.getCategories()); - } - - return entry; - } - - public SyndEntry asRss() { - SyndEntry entry = new SyndEntryImpl(); - - entry.setUri(getGuid()); - entry.setTitle(getTitle()); - entry.setAuthor(getAuthor()); - - SyndContentImpl content = new SyndContentImpl(); - content.setValue(getContent()); - entry.setContents(Collections.singletonList(content)); - - if (getEnclosureUrl() != null) { - SyndEnclosureImpl enclosure = new SyndEnclosureImpl(); - enclosure.setType(getEnclosureType()); - enclosure.setUrl(getEnclosureUrl()); - entry.setEnclosures(Collections.singletonList(enclosure)); - } - - entry.setLink(getUrl()); - entry.setPublishedDate(getDate() == null ? null : Date.from(getDate())); - return entry; - } -} +package com.commafeed.frontend.model; + +import java.io.Serializable; +import java.time.Instant; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; + +import com.commafeed.backend.feed.FeedUtils; +import com.commafeed.backend.model.FeedEntry; +import com.commafeed.backend.model.FeedEntryContent; +import com.commafeed.backend.model.FeedEntryStatus; +import com.commafeed.backend.model.FeedEntryTag; +import com.commafeed.backend.model.FeedSubscription; +import com.rometools.rome.feed.synd.SyndContentImpl; +import com.rometools.rome.feed.synd.SyndEnclosureImpl; +import com.rometools.rome.feed.synd.SyndEntry; +import com.rometools.rome.feed.synd.SyndEntryImpl; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema(description = "Entry details") +@Data +@RegisterForReflection +public class Entry implements Serializable { + + @Schema(description = "entry id", requiredMode = RequiredMode.REQUIRED) + private String id; + + @Schema(description = "entry guid", requiredMode = RequiredMode.REQUIRED) + private String guid; + + @Schema(description = "entry title", requiredMode = RequiredMode.REQUIRED) + private String title; + + @Schema(description = "entry content", requiredMode = RequiredMode.REQUIRED) + private String content; + + @Schema(description = "comma-separated list of categories") + private String categories; + + @Schema(description = "whether entry content and title are rtl", requiredMode = RequiredMode.REQUIRED) + private boolean rtl; + + @Schema(description = "entry author") + private String author; + + @Schema(description = "entry enclosure url, if any") + private String enclosureUrl; + + @Schema(description = "entry enclosure mime type, if any") + private String enclosureType; + + @Schema(description = "entry media description, if any") + private String mediaDescription; + + @Schema(description = "entry media thumbnail url, if any") + private String mediaThumbnailUrl; + + @Schema(description = "entry media thumbnail width, if any") + private Integer mediaThumbnailWidth; + + @Schema(description = "entry media thumbnail height, if any") + private Integer mediaThumbnailHeight; + + @Schema(description = "entry publication date", type = "number", requiredMode = RequiredMode.REQUIRED) + private Instant date; + + @Schema(description = "entry insertion date in the database", type = "number", requiredMode = RequiredMode.REQUIRED) + private Instant insertedDate; + + @Schema(description = "feed id", requiredMode = RequiredMode.REQUIRED) + private String feedId; + + @Schema(description = "feed name", requiredMode = RequiredMode.REQUIRED) + private String feedName; + + @Schema(description = "this entry's feed url", requiredMode = RequiredMode.REQUIRED) + private String feedUrl; + + @Schema(description = "this entry's website url", requiredMode = RequiredMode.REQUIRED) + private String feedLink; + + @Schema(description = "The favicon url to use for this feed", requiredMode = RequiredMode.REQUIRED) + private String iconUrl; + + @Schema(description = "entry url", requiredMode = RequiredMode.REQUIRED) + private String url; + + @Schema(description = "read status", requiredMode = RequiredMode.REQUIRED) + private boolean read; + + @Schema(description = "starred status", requiredMode = RequiredMode.REQUIRED) + private boolean starred; + + @Schema(description = "whether the entry is still markable (old entry statuses are discarded)", requiredMode = RequiredMode.REQUIRED) + private boolean markable; + + @Schema(description = "tags", requiredMode = RequiredMode.REQUIRED) + private List tags; + + public static Entry build(FeedEntryStatus status, boolean proxyImages) { + Entry entry = new Entry(); + + FeedEntry feedEntry = status.getEntry(); + FeedSubscription sub = status.getSubscription(); + FeedEntryContent content = feedEntry.getContent(); + + entry.setId(String.valueOf(feedEntry.getId())); + entry.setGuid(feedEntry.getGuid()); + entry.setRead(status.isRead()); + entry.setStarred(status.isStarred()); + entry.setMarkable(status.isMarkable()); + entry.setDate(feedEntry.getPublished()); + entry.setInsertedDate(feedEntry.getInserted()); + entry.setUrl(feedEntry.getUrl()); + entry.setFeedName(sub.getTitle()); + entry.setFeedId(String.valueOf(sub.getId())); + entry.setFeedUrl(sub.getFeed().getUrl()); + entry.setFeedLink(sub.getFeed().getLink()); + entry.setIconUrl(FeedUtils.getFaviconUrl(sub)); + entry.setTags(status.getTags().stream().map(FeedEntryTag::getName).toList()); + + if (content != null) { + entry.setRtl(content.isRTL()); + entry.setTitle(content.getTitle()); + entry.setContent(proxyImages ? FeedUtils.proxyImages(content.getContent()) : content.getContent()); + entry.setAuthor(content.getAuthor()); + + entry.setEnclosureType(content.getEnclosureType()); + entry.setEnclosureUrl(proxyImages && StringUtils.contains(content.getEnclosureType(), "image") + ? FeedUtils.proxyImage(content.getEnclosureUrl()) + : content.getEnclosureUrl()); + + entry.setMediaDescription(content.getMediaDescription()); + entry.setMediaThumbnailUrl(proxyImages ? FeedUtils.proxyImage(content.getMediaThumbnailUrl()) : content.getMediaThumbnailUrl()); + entry.setMediaThumbnailWidth(content.getMediaThumbnailWidth()); + entry.setMediaThumbnailHeight(content.getMediaThumbnailHeight()); + + entry.setCategories(content.getCategories()); + } + + return entry; + } + + public SyndEntry asRss() { + SyndEntry entry = new SyndEntryImpl(); + + entry.setUri(getGuid()); + entry.setTitle(getTitle()); + entry.setAuthor(getAuthor()); + + SyndContentImpl content = new SyndContentImpl(); + content.setValue(getContent()); + entry.setContents(Collections.singletonList(content)); + + if (getEnclosureUrl() != null) { + SyndEnclosureImpl enclosure = new SyndEnclosureImpl(); + enclosure.setType(getEnclosureType()); + enclosure.setUrl(getEnclosureUrl()); + entry.setEnclosures(Collections.singletonList(enclosure)); + } + + entry.setLink(getUrl()); + entry.setPublishedDate(getDate() == null ? null : Date.from(getDate())); + return entry; + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/FeedInfo.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/FeedInfo.java index 643ad6a5..f908782b 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/FeedInfo.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/FeedInfo.java @@ -1,22 +1,22 @@ -package com.commafeed.frontend.model; - -import java.io.Serializable; - -import io.quarkus.runtime.annotations.RegisterForReflection; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema(description = "Feed details") -@Data -@RegisterForReflection -public class FeedInfo implements Serializable { - - @Schema(description = "url", requiredMode = RequiredMode.REQUIRED) - private String url; - - @Schema(description = "title", requiredMode = RequiredMode.REQUIRED) - private String title; - -} +package com.commafeed.frontend.model; + +import java.io.Serializable; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema(description = "Feed details") +@Data +@RegisterForReflection +public class FeedInfo implements Serializable { + + @Schema(description = "url", requiredMode = RequiredMode.REQUIRED) + private String url; + + @Schema(description = "title", requiredMode = RequiredMode.REQUIRED) + private String title; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/ServerInfo.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/ServerInfo.java index c0198047..5e557859 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/ServerInfo.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/ServerInfo.java @@ -1,49 +1,49 @@ -package com.commafeed.frontend.model; - -import java.io.Serializable; - -import io.quarkus.runtime.annotations.RegisterForReflection; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema(description = "Server infos") -@Data -@RegisterForReflection -public class ServerInfo implements Serializable { - - @Schema - private String announcement; - - @Schema(requiredMode = RequiredMode.REQUIRED) - private String version; - - @Schema(requiredMode = RequiredMode.REQUIRED) - private String gitCommit; - - @Schema(requiredMode = RequiredMode.REQUIRED) - private boolean allowRegistrations; - - @Schema - private String googleAnalyticsCode; - - @Schema(requiredMode = RequiredMode.REQUIRED) - private boolean smtpEnabled; - - @Schema(requiredMode = RequiredMode.REQUIRED) - private boolean demoAccountEnabled; - - @Schema(requiredMode = RequiredMode.REQUIRED) - private boolean websocketEnabled; - - @Schema(requiredMode = RequiredMode.REQUIRED) - private long websocketPingInterval; - - @Schema(requiredMode = RequiredMode.REQUIRED) - private long treeReloadInterval; - - @Schema(requiredMode = RequiredMode.REQUIRED) - private long forceRefreshCooldownDuration; - -} +package com.commafeed.frontend.model; + +import java.io.Serializable; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema(description = "Server infos") +@Data +@RegisterForReflection +public class ServerInfo implements Serializable { + + @Schema + private String announcement; + + @Schema(requiredMode = RequiredMode.REQUIRED) + private String version; + + @Schema(requiredMode = RequiredMode.REQUIRED) + private String gitCommit; + + @Schema(requiredMode = RequiredMode.REQUIRED) + private boolean allowRegistrations; + + @Schema + private String googleAnalyticsCode; + + @Schema(requiredMode = RequiredMode.REQUIRED) + private boolean smtpEnabled; + + @Schema(requiredMode = RequiredMode.REQUIRED) + private boolean demoAccountEnabled; + + @Schema(requiredMode = RequiredMode.REQUIRED) + private boolean websocketEnabled; + + @Schema(requiredMode = RequiredMode.REQUIRED) + private long websocketPingInterval; + + @Schema(requiredMode = RequiredMode.REQUIRED) + private long treeReloadInterval; + + @Schema(requiredMode = RequiredMode.REQUIRED) + private long forceRefreshCooldownDuration; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/Settings.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/Settings.java index 8a35c9ac..1a772275 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/Settings.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/Settings.java @@ -1,112 +1,112 @@ -package com.commafeed.frontend.model; - -import java.io.Serializable; - -import io.quarkus.runtime.annotations.RegisterForReflection; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema(description = "User settings") -@Data -@RegisterForReflection -public class Settings implements Serializable { - - @Schema(description = "user's preferred language, english if none", requiredMode = RequiredMode.REQUIRED) - private String language; - - @Schema( - description = "user reads all entries or unread entries only", - allowableValues = "all,unread", - requiredMode = RequiredMode.REQUIRED) - private String readingMode; - - @Schema( - description = "user reads entries in ascending or descending order", - allowableValues = "asc,desc", - requiredMode = RequiredMode.REQUIRED) - private String readingOrder; - - @Schema(description = "user wants category and feeds with no unread entries shown", requiredMode = RequiredMode.REQUIRED) - private boolean showRead; - - @Schema(description = "In expanded view, scroll through entries mark them as read", requiredMode = RequiredMode.REQUIRED) - private boolean scrollMarks; - - @Schema(description = "user's custom css for the website") - private String customCss; - - @Schema(description = "user's custom js for the website") - private String customJs; - - @Schema(description = "user's preferred scroll speed when navigating between entries", requiredMode = RequiredMode.REQUIRED) - private int scrollSpeed; - - @Schema( - description = "whether to scroll to the selected entry", - allowableValues = "always,never,if_needed", - requiredMode = RequiredMode.REQUIRED) - private String scrollMode; - - @Schema(description = "number of entries to keep above the selected entry when scrolling", requiredMode = RequiredMode.REQUIRED) - private int entriesToKeepOnTopWhenScrolling; - - @Schema( - description = "whether to show the star icon in the header of entries", - allowableValues = "always,never,on_desktop,on_mobile", - requiredMode = RequiredMode.REQUIRED) - private String starIconDisplayMode; - - @Schema( - description = "whether to show the external link icon in the header of entries", - allowableValues = "always,never,on_desktop,on_mobile", - requiredMode = RequiredMode.REQUIRED) - private String externalLinkIconDisplayMode; - - @Schema(description = "ask for confirmation when marking all entries as read", requiredMode = RequiredMode.REQUIRED) - private boolean markAllAsReadConfirmation; - - @Schema(description = "show commafeed's own context menu on right click", requiredMode = RequiredMode.REQUIRED) - private boolean customContextMenu; - - @Schema(description = "on mobile, show action buttons at the bottom of the screen", requiredMode = RequiredMode.REQUIRED) - private boolean mobileFooter; - - @Schema(description = "show unread count in the title", requiredMode = RequiredMode.REQUIRED) - private boolean unreadCountTitle; - - @Schema(description = "show unread count in the favicon", requiredMode = RequiredMode.REQUIRED) - private boolean unreadCountFavicon; - - @Schema(description = "sharing settings", requiredMode = RequiredMode.REQUIRED) - private SharingSettings sharingSettings = new SharingSettings(); - - @Schema(description = "User sharing settings") - @Data - public static class SharingSettings implements Serializable { - @Schema(requiredMode = RequiredMode.REQUIRED) - private boolean email; - - @Schema(requiredMode = RequiredMode.REQUIRED) - private boolean gmail; - - @Schema(requiredMode = RequiredMode.REQUIRED) - private boolean facebook; - - @Schema(requiredMode = RequiredMode.REQUIRED) - private boolean twitter; - - @Schema(requiredMode = RequiredMode.REQUIRED) - private boolean tumblr; - - @Schema(requiredMode = RequiredMode.REQUIRED) - private boolean pocket; - - @Schema(requiredMode = RequiredMode.REQUIRED) - private boolean instapaper; - - @Schema(requiredMode = RequiredMode.REQUIRED) - private boolean buffer; - } -} +package com.commafeed.frontend.model; + +import java.io.Serializable; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema(description = "User settings") +@Data +@RegisterForReflection +public class Settings implements Serializable { + + @Schema(description = "user's preferred language, english if none", requiredMode = RequiredMode.REQUIRED) + private String language; + + @Schema( + description = "user reads all entries or unread entries only", + allowableValues = "all,unread", + requiredMode = RequiredMode.REQUIRED) + private String readingMode; + + @Schema( + description = "user reads entries in ascending or descending order", + allowableValues = "asc,desc", + requiredMode = RequiredMode.REQUIRED) + private String readingOrder; + + @Schema(description = "user wants category and feeds with no unread entries shown", requiredMode = RequiredMode.REQUIRED) + private boolean showRead; + + @Schema(description = "In expanded view, scroll through entries mark them as read", requiredMode = RequiredMode.REQUIRED) + private boolean scrollMarks; + + @Schema(description = "user's custom css for the website") + private String customCss; + + @Schema(description = "user's custom js for the website") + private String customJs; + + @Schema(description = "user's preferred scroll speed when navigating between entries", requiredMode = RequiredMode.REQUIRED) + private int scrollSpeed; + + @Schema( + description = "whether to scroll to the selected entry", + allowableValues = "always,never,if_needed", + requiredMode = RequiredMode.REQUIRED) + private String scrollMode; + + @Schema(description = "number of entries to keep above the selected entry when scrolling", requiredMode = RequiredMode.REQUIRED) + private int entriesToKeepOnTopWhenScrolling; + + @Schema( + description = "whether to show the star icon in the header of entries", + allowableValues = "always,never,on_desktop,on_mobile", + requiredMode = RequiredMode.REQUIRED) + private String starIconDisplayMode; + + @Schema( + description = "whether to show the external link icon in the header of entries", + allowableValues = "always,never,on_desktop,on_mobile", + requiredMode = RequiredMode.REQUIRED) + private String externalLinkIconDisplayMode; + + @Schema(description = "ask for confirmation when marking all entries as read", requiredMode = RequiredMode.REQUIRED) + private boolean markAllAsReadConfirmation; + + @Schema(description = "show commafeed's own context menu on right click", requiredMode = RequiredMode.REQUIRED) + private boolean customContextMenu; + + @Schema(description = "on mobile, show action buttons at the bottom of the screen", requiredMode = RequiredMode.REQUIRED) + private boolean mobileFooter; + + @Schema(description = "show unread count in the title", requiredMode = RequiredMode.REQUIRED) + private boolean unreadCountTitle; + + @Schema(description = "show unread count in the favicon", requiredMode = RequiredMode.REQUIRED) + private boolean unreadCountFavicon; + + @Schema(description = "sharing settings", requiredMode = RequiredMode.REQUIRED) + private SharingSettings sharingSettings = new SharingSettings(); + + @Schema(description = "User sharing settings") + @Data + public static class SharingSettings implements Serializable { + @Schema(requiredMode = RequiredMode.REQUIRED) + private boolean email; + + @Schema(requiredMode = RequiredMode.REQUIRED) + private boolean gmail; + + @Schema(requiredMode = RequiredMode.REQUIRED) + private boolean facebook; + + @Schema(requiredMode = RequiredMode.REQUIRED) + private boolean twitter; + + @Schema(requiredMode = RequiredMode.REQUIRED) + private boolean tumblr; + + @Schema(requiredMode = RequiredMode.REQUIRED) + private boolean pocket; + + @Schema(requiredMode = RequiredMode.REQUIRED) + private boolean instapaper; + + @Schema(requiredMode = RequiredMode.REQUIRED) + private boolean buffer; + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/Subscription.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/Subscription.java index 902de115..23450a9c 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/Subscription.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/Subscription.java @@ -1,86 +1,86 @@ -package com.commafeed.frontend.model; - -import java.io.Serializable; -import java.time.Instant; - -import com.commafeed.backend.feed.FeedUtils; -import com.commafeed.backend.model.Feed; -import com.commafeed.backend.model.FeedCategory; -import com.commafeed.backend.model.FeedSubscription; - -import io.quarkus.runtime.annotations.RegisterForReflection; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema(description = "User information") -@Data -@RegisterForReflection -public class Subscription implements Serializable { - - @Schema(description = "subscription id", requiredMode = RequiredMode.REQUIRED) - private Long id; - - @Schema(description = "subscription name", requiredMode = RequiredMode.REQUIRED) - private String name; - - @Schema(description = "error message while fetching the feed") - private String message; - - @Schema(description = "error count", requiredMode = RequiredMode.REQUIRED) - private int errorCount; - - @Schema(description = "last time the feed was refreshed", type = "number") - private Instant lastRefresh; - - @Schema(description = "next time the feed refresh is planned, null if refresh is already queued", type = "number") - private Instant nextRefresh; - - @Schema(description = "this subscription's feed url", requiredMode = RequiredMode.REQUIRED) - private String feedUrl; - - @Schema(description = "this subscription's website url", requiredMode = RequiredMode.REQUIRED) - private String feedLink; - - @Schema(description = "The favicon url to use for this feed", requiredMode = RequiredMode.REQUIRED) - private String iconUrl; - - @Schema(description = "unread count", requiredMode = RequiredMode.REQUIRED) - private long unread; - - @Schema(description = "category id") - private String categoryId; - - @Schema(description = "position of the subscription's in the list") - private int position; - - @Schema(description = "date of the newest item", type = "number") - private Instant newestItemTime; - - @Schema(description = "JEXL string evaluated on new entries to mark them as read if they do not match") - private String filter; - - public static Subscription build(FeedSubscription subscription, UnreadCount unreadCount) { - FeedCategory category = subscription.getCategory(); - Feed feed = subscription.getFeed(); - Subscription sub = new Subscription(); - sub.setId(subscription.getId()); - sub.setName(subscription.getTitle()); - sub.setPosition(subscription.getPosition()); - sub.setMessage(feed.getMessage()); - sub.setErrorCount(feed.getErrorCount()); - sub.setFeedUrl(feed.getUrl()); - sub.setFeedLink(feed.getLink()); - sub.setIconUrl(FeedUtils.getFaviconUrl(subscription)); - sub.setLastRefresh(feed.getLastUpdated()); - sub.setNextRefresh( - (feed.getDisabledUntil() != null && feed.getDisabledUntil().isBefore(Instant.now())) ? null : feed.getDisabledUntil()); - sub.setUnread(unreadCount.getUnreadCount()); - sub.setNewestItemTime(unreadCount.getNewestItemTime()); - sub.setCategoryId(category == null ? null : String.valueOf(category.getId())); - sub.setFilter(subscription.getFilter()); - return sub; - } - +package com.commafeed.frontend.model; + +import java.io.Serializable; +import java.time.Instant; + +import com.commafeed.backend.feed.FeedUtils; +import com.commafeed.backend.model.Feed; +import com.commafeed.backend.model.FeedCategory; +import com.commafeed.backend.model.FeedSubscription; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema(description = "User information") +@Data +@RegisterForReflection +public class Subscription implements Serializable { + + @Schema(description = "subscription id", requiredMode = RequiredMode.REQUIRED) + private Long id; + + @Schema(description = "subscription name", requiredMode = RequiredMode.REQUIRED) + private String name; + + @Schema(description = "error message while fetching the feed") + private String message; + + @Schema(description = "error count", requiredMode = RequiredMode.REQUIRED) + private int errorCount; + + @Schema(description = "last time the feed was refreshed", type = "number") + private Instant lastRefresh; + + @Schema(description = "next time the feed refresh is planned, null if refresh is already queued", type = "number") + private Instant nextRefresh; + + @Schema(description = "this subscription's feed url", requiredMode = RequiredMode.REQUIRED) + private String feedUrl; + + @Schema(description = "this subscription's website url", requiredMode = RequiredMode.REQUIRED) + private String feedLink; + + @Schema(description = "The favicon url to use for this feed", requiredMode = RequiredMode.REQUIRED) + private String iconUrl; + + @Schema(description = "unread count", requiredMode = RequiredMode.REQUIRED) + private long unread; + + @Schema(description = "category id") + private String categoryId; + + @Schema(description = "position of the subscription's in the list") + private int position; + + @Schema(description = "date of the newest item", type = "number") + private Instant newestItemTime; + + @Schema(description = "JEXL string evaluated on new entries to mark them as read if they do not match") + private String filter; + + public static Subscription build(FeedSubscription subscription, UnreadCount unreadCount) { + FeedCategory category = subscription.getCategory(); + Feed feed = subscription.getFeed(); + Subscription sub = new Subscription(); + sub.setId(subscription.getId()); + sub.setName(subscription.getTitle()); + sub.setPosition(subscription.getPosition()); + sub.setMessage(feed.getMessage()); + sub.setErrorCount(feed.getErrorCount()); + sub.setFeedUrl(feed.getUrl()); + sub.setFeedLink(feed.getLink()); + sub.setIconUrl(FeedUtils.getFaviconUrl(subscription)); + sub.setLastRefresh(feed.getLastUpdated()); + sub.setNextRefresh( + (feed.getDisabledUntil() != null && feed.getDisabledUntil().isBefore(Instant.now())) ? null : feed.getDisabledUntil()); + sub.setUnread(unreadCount.getUnreadCount()); + sub.setNewestItemTime(unreadCount.getNewestItemTime()); + sub.setCategoryId(category == null ? null : String.valueOf(category.getId())); + sub.setFilter(subscription.getFilter()); + return sub; + } + } \ No newline at end of file diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/UnreadCount.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/UnreadCount.java index 6ecd7a1a..f4182984 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/UnreadCount.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/UnreadCount.java @@ -1,34 +1,34 @@ -package com.commafeed.frontend.model; - -import java.io.Serializable; -import java.time.Instant; - -import io.quarkus.runtime.annotations.RegisterForReflection; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema(description = "Unread count") -@Data -@RegisterForReflection -public class UnreadCount implements Serializable { - - @Schema - private long feedId; - - @Schema - private long unreadCount; - - @Schema(type = "number") - private Instant newestItemTime; - - public UnreadCount() { - } - - public UnreadCount(long feedId, long unreadCount, Instant newestItemTime) { - this.feedId = feedId; - this.unreadCount = unreadCount; - this.newestItemTime = newestItemTime; - } - -} +package com.commafeed.frontend.model; + +import java.io.Serializable; +import java.time.Instant; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema(description = "Unread count") +@Data +@RegisterForReflection +public class UnreadCount implements Serializable { + + @Schema + private long feedId; + + @Schema + private long unreadCount; + + @Schema(type = "number") + private Instant newestItemTime; + + public UnreadCount() { + } + + public UnreadCount(long feedId, long unreadCount, Instant newestItemTime) { + this.feedId = feedId; + this.unreadCount = unreadCount; + this.newestItemTime = newestItemTime; + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/UserModel.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/UserModel.java index 1b1bb7c9..f666a6e2 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/UserModel.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/UserModel.java @@ -1,47 +1,47 @@ -package com.commafeed.frontend.model; - -import java.io.Serializable; -import java.time.Instant; - -import io.quarkus.runtime.annotations.RegisterForReflection; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema(description = "User information") -@Data -@RegisterForReflection -public class UserModel implements Serializable { - - @Schema(description = "user id", requiredMode = RequiredMode.REQUIRED) - private Long id; - - @Schema(description = "user name", requiredMode = RequiredMode.REQUIRED) - private String name; - - @Schema(description = "user email, if any") - private String email; - - @Schema(description = "api key") - private String apiKey; - - @Schema(description = "user password, never returned by the api") - private String password; - - @Schema(description = "account status", requiredMode = RequiredMode.REQUIRED) - private boolean enabled; - - @Schema(description = "account creation date", type = "number") - private Instant created; - - @Schema(description = "last login date", type = "number") - private Instant lastLogin; - - @Schema(description = "user is admin", requiredMode = RequiredMode.REQUIRED) - private boolean admin; - - @Schema(description = "user last force refresh", type = "number") - private Instant lastForceRefresh; - -} +package com.commafeed.frontend.model; + +import java.io.Serializable; +import java.time.Instant; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema(description = "User information") +@Data +@RegisterForReflection +public class UserModel implements Serializable { + + @Schema(description = "user id", requiredMode = RequiredMode.REQUIRED) + private Long id; + + @Schema(description = "user name", requiredMode = RequiredMode.REQUIRED) + private String name; + + @Schema(description = "user email, if any") + private String email; + + @Schema(description = "api key") + private String apiKey; + + @Schema(description = "user password, never returned by the api") + private String password; + + @Schema(description = "account status", requiredMode = RequiredMode.REQUIRED) + private boolean enabled; + + @Schema(description = "account creation date", type = "number") + private Instant created; + + @Schema(description = "last login date", type = "number") + private Instant lastLogin; + + @Schema(description = "user is admin", requiredMode = RequiredMode.REQUIRED) + private boolean admin; + + @Schema(description = "user last force refresh", type = "number") + private Instant lastForceRefresh; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/AddCategoryRequest.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/AddCategoryRequest.java index 8a584b17..68ee932b 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/AddCategoryRequest.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/AddCategoryRequest.java @@ -1,26 +1,26 @@ -package com.commafeed.frontend.model.request; - -import java.io.Serializable; - -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.Size; - -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema(description = "Add Category Request") -@Data -public class AddCategoryRequest implements Serializable { - - @Schema(description = "name", requiredMode = RequiredMode.REQUIRED) - @NotEmpty - @Size(max = 128) - private String name; - - @Schema(description = "parent category id, if any") - @Size(max = 128) - private String parentId; - -} +package com.commafeed.frontend.model.request; + +import java.io.Serializable; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema(description = "Add Category Request") +@Data +public class AddCategoryRequest implements Serializable { + + @Schema(description = "name", requiredMode = RequiredMode.REQUIRED) + @NotEmpty + @Size(max = 128) + private String name; + + @Schema(description = "parent category id, if any") + @Size(max = 128) + private String parentId; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/AdminSaveUserRequest.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/AdminSaveUserRequest.java index d668ec44..59365c0c 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/AdminSaveUserRequest.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/AdminSaveUserRequest.java @@ -1,31 +1,31 @@ -package com.commafeed.frontend.model.request; - -import java.io.Serializable; - -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema(description = "Save User information") -@Data -public class AdminSaveUserRequest implements Serializable { - - @Schema(description = "user id") - private Long id; - - @Schema(description = "user name", requiredMode = RequiredMode.REQUIRED) - private String name; - - @Schema(description = "user email, if any") - private String email; - - @Schema(description = "user password") - private String password; - - @Schema(description = "account status", requiredMode = RequiredMode.REQUIRED) - private boolean enabled; - - @Schema(description = "user is admin", requiredMode = RequiredMode.REQUIRED) - private boolean admin; -} +package com.commafeed.frontend.model.request; + +import java.io.Serializable; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema(description = "Save User information") +@Data +public class AdminSaveUserRequest implements Serializable { + + @Schema(description = "user id") + private Long id; + + @Schema(description = "user name", requiredMode = RequiredMode.REQUIRED) + private String name; + + @Schema(description = "user email, if any") + private String email; + + @Schema(description = "user password") + private String password; + + @Schema(description = "account status", requiredMode = RequiredMode.REQUIRED) + private boolean enabled; + + @Schema(description = "user is admin", requiredMode = RequiredMode.REQUIRED) + private boolean admin; +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/CategoryModificationRequest.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/CategoryModificationRequest.java index e38c55f0..dc4aecbf 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/CategoryModificationRequest.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/CategoryModificationRequest.java @@ -1,30 +1,30 @@ -package com.commafeed.frontend.model.request; - -import java.io.Serializable; - -import jakarta.validation.constraints.Size; - -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema(description = "Category modification request") -@Data -public class CategoryModificationRequest implements Serializable { - - @Schema(description = "id", requiredMode = RequiredMode.REQUIRED) - private Long id; - - @Schema(description = "new name, null if not changed") - @Size(max = 128) - private String name; - - @Schema(description = "new parent category id") - @Size(max = 128) - private String parentId; - - @Schema(description = "new display position, null if not changed") - private Integer position; - -} +package com.commafeed.frontend.model.request; + +import java.io.Serializable; + +import jakarta.validation.constraints.Size; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema(description = "Category modification request") +@Data +public class CategoryModificationRequest implements Serializable { + + @Schema(description = "id", requiredMode = RequiredMode.REQUIRED) + private Long id; + + @Schema(description = "new name, null if not changed") + @Size(max = 128) + private String name; + + @Schema(description = "new parent category id") + @Size(max = 128) + private String parentId; + + @Schema(description = "new display position, null if not changed") + private Integer position; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/CollapseRequest.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/CollapseRequest.java index 87937427..52991d01 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/CollapseRequest.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/CollapseRequest.java @@ -1,20 +1,20 @@ -package com.commafeed.frontend.model.request; - -import java.io.Serializable; - -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema(description = "Mark Request") -@Data -public class CollapseRequest implements Serializable { - - @Schema(description = "category id", requiredMode = RequiredMode.REQUIRED) - private Long id; - - @Schema(description = "collapse", requiredMode = RequiredMode.REQUIRED) - private boolean collapse; - -} +package com.commafeed.frontend.model.request; + +import java.io.Serializable; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema(description = "Mark Request") +@Data +public class CollapseRequest implements Serializable { + + @Schema(description = "category id", requiredMode = RequiredMode.REQUIRED) + private Long id; + + @Schema(description = "collapse", requiredMode = RequiredMode.REQUIRED) + private boolean collapse; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/FeedInfoRequest.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/FeedInfoRequest.java index f7d2e2b0..e16e9d5e 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/FeedInfoRequest.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/FeedInfoRequest.java @@ -1,22 +1,22 @@ -package com.commafeed.frontend.model.request; - -import java.io.Serializable; - -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.Size; - -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema(description = "Feed information request") -@Data -public class FeedInfoRequest implements Serializable { - - @Schema(description = "feed url", requiredMode = RequiredMode.REQUIRED) - @NotEmpty - @Size(max = 4096) - private String url; - -} +package com.commafeed.frontend.model.request; + +import java.io.Serializable; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema(description = "Feed information request") +@Data +public class FeedInfoRequest implements Serializable { + + @Schema(description = "feed url", requiredMode = RequiredMode.REQUIRED) + @NotEmpty + @Size(max = 4096) + private String url; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/FeedModificationRequest.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/FeedModificationRequest.java index 270ef488..f6b24854 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/FeedModificationRequest.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/FeedModificationRequest.java @@ -1,34 +1,34 @@ -package com.commafeed.frontend.model.request; - -import java.io.Serializable; - -import jakarta.validation.constraints.Size; - -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema(description = "Feed modification request") -@Data -public class FeedModificationRequest implements Serializable { - - @Schema(description = "id", requiredMode = RequiredMode.REQUIRED) - private Long id; - - @Schema(description = "new name, null if not changed") - @Size(max = 128) - private String name; - - @Schema(description = "new parent category id") - @Size(max = 128) - private String categoryId; - - @Schema(description = "new display position, null if not changed") - private Integer position; - - @Schema(description = "JEXL string evaluated on new entries to mark them as read if they do not match") - @Size(max = 4096) - private String filter; - -} +package com.commafeed.frontend.model.request; + +import java.io.Serializable; + +import jakarta.validation.constraints.Size; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema(description = "Feed modification request") +@Data +public class FeedModificationRequest implements Serializable { + + @Schema(description = "id", requiredMode = RequiredMode.REQUIRED) + private Long id; + + @Schema(description = "new name, null if not changed") + @Size(max = 128) + private String name; + + @Schema(description = "new parent category id") + @Size(max = 128) + private String categoryId; + + @Schema(description = "new display position, null if not changed") + private Integer position; + + @Schema(description = "JEXL string evaluated on new entries to mark them as read if they do not match") + @Size(max = 4096) + private String filter; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/IDRequest.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/IDRequest.java index 38810d9a..90f60c75 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/IDRequest.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/IDRequest.java @@ -1,17 +1,17 @@ -package com.commafeed.frontend.model.request; - -import java.io.Serializable; - -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema -@Data -public class IDRequest implements Serializable { - - @Schema(requiredMode = RequiredMode.REQUIRED) - private Long id; - -} +package com.commafeed.frontend.model.request; + +import java.io.Serializable; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema +@Data +public class IDRequest implements Serializable { + + @Schema(requiredMode = RequiredMode.REQUIRED) + private Long id; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/MarkRequest.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/MarkRequest.java index 68d9a21f..cee69182 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/MarkRequest.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/MarkRequest.java @@ -1,45 +1,45 @@ -package com.commafeed.frontend.model.request; - -import java.io.Serializable; -import java.util.List; - -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.Size; - -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema(description = "Mark Request") -@Data -public class MarkRequest implements Serializable { - - @Schema(description = "entry id, category id, 'all' or 'starred'", requiredMode = RequiredMode.REQUIRED) - @NotEmpty - @Size(max = 128) - private String id; - - @Schema(description = "mark as read or unread", requiredMode = RequiredMode.REQUIRED) - private boolean read; - - @Schema(description = "mark only entries older than this", requiredMode = RequiredMode.NOT_REQUIRED) - private Long olderThan; - - @Schema( - description = "pass the timestamp you got from the entry list to avoid marking entries that may have been fetched in the mean time and never displayed", - requiredMode = RequiredMode.NOT_REQUIRED) - private Long insertedBefore; - - @Schema( - description = "only mark read if a feed has these keywords in the title or rss content", - requiredMode = RequiredMode.NOT_REQUIRED) - @Size(max = 128) - private String keywords; - - @Schema( - description = "if marking a category or 'all', exclude those subscriptions from the marking", - requiredMode = RequiredMode.NOT_REQUIRED) - private List excludedSubscriptions; - -} +package com.commafeed.frontend.model.request; + +import java.io.Serializable; +import java.util.List; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema(description = "Mark Request") +@Data +public class MarkRequest implements Serializable { + + @Schema(description = "entry id, category id, 'all' or 'starred'", requiredMode = RequiredMode.REQUIRED) + @NotEmpty + @Size(max = 128) + private String id; + + @Schema(description = "mark as read or unread", requiredMode = RequiredMode.REQUIRED) + private boolean read; + + @Schema(description = "mark only entries older than this", requiredMode = RequiredMode.NOT_REQUIRED) + private Long olderThan; + + @Schema( + description = "pass the timestamp you got from the entry list to avoid marking entries that may have been fetched in the mean time and never displayed", + requiredMode = RequiredMode.NOT_REQUIRED) + private Long insertedBefore; + + @Schema( + description = "only mark read if a feed has these keywords in the title or rss content", + requiredMode = RequiredMode.NOT_REQUIRED) + @Size(max = 128) + private String keywords; + + @Schema( + description = "if marking a category or 'all', exclude those subscriptions from the marking", + requiredMode = RequiredMode.NOT_REQUIRED) + private List excludedSubscriptions; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/MultipleMarkRequest.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/MultipleMarkRequest.java index 582ac0ca..8b7b2cc2 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/MultipleMarkRequest.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/MultipleMarkRequest.java @@ -1,20 +1,20 @@ -package com.commafeed.frontend.model.request; - -import java.io.Serializable; -import java.util.List; - -import jakarta.validation.Valid; - -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema(description = "Multiple Mark Request") -@Data -public class MultipleMarkRequest implements Serializable { - - @Schema(description = "list of mark requests", requiredMode = RequiredMode.REQUIRED) - private List<@Valid MarkRequest> requests; - -} +package com.commafeed.frontend.model.request; + +import java.io.Serializable; +import java.util.List; + +import jakarta.validation.Valid; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema(description = "Multiple Mark Request") +@Data +public class MultipleMarkRequest implements Serializable { + + @Schema(description = "list of mark requests", requiredMode = RequiredMode.REQUIRED) + private List<@Valid MarkRequest> requests; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/PasswordResetRequest.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/PasswordResetRequest.java index f801e3eb..f69770b4 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/PasswordResetRequest.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/PasswordResetRequest.java @@ -1,23 +1,23 @@ -package com.commafeed.frontend.model.request; - -import java.io.Serializable; - -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.Size; - -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Data -@Schema -public class PasswordResetRequest implements Serializable { - - @Schema(description = "email address for password recovery", requiredMode = RequiredMode.REQUIRED) - @Email - @NotEmpty - @Size(max = 255) - private String email; -} +package com.commafeed.frontend.model.request; + +import java.io.Serializable; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Data +@Schema +public class PasswordResetRequest implements Serializable { + + @Schema(description = "email address for password recovery", requiredMode = RequiredMode.REQUIRED) + @Email + @NotEmpty + @Size(max = 255) + private String email; +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/ProfileModificationRequest.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/ProfileModificationRequest.java index 4f1ad0f5..1936d931 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/ProfileModificationRequest.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/ProfileModificationRequest.java @@ -1,34 +1,34 @@ -package com.commafeed.frontend.model.request; - -import java.io.Serializable; - -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.Size; - -import com.commafeed.security.password.ValidPassword; - -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema(description = "Profile modification request") -@Data -public class ProfileModificationRequest implements Serializable { - @Schema(description = "current user password, required to change profile data", requiredMode = RequiredMode.REQUIRED) - @NotEmpty - @Size(max = 128) - private String currentPassword; - - @Schema(description = "changes email of the user, if specified") - @Size(max = 255) - private String email; - - @Schema(description = "changes password of the user, if specified") - @ValidPassword - private String newPassword; - - @Schema(description = "generate a new api key") - private boolean newApiKey; - -} +package com.commafeed.frontend.model.request; + +import java.io.Serializable; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +import com.commafeed.security.password.ValidPassword; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema(description = "Profile modification request") +@Data +public class ProfileModificationRequest implements Serializable { + @Schema(description = "current user password, required to change profile data", requiredMode = RequiredMode.REQUIRED) + @NotEmpty + @Size(max = 128) + private String currentPassword; + + @Schema(description = "changes email of the user, if specified") + @Size(max = 255) + private String email; + + @Schema(description = "changes password of the user, if specified") + @ValidPassword + private String newPassword; + + @Schema(description = "generate a new api key") + private boolean newApiKey; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/RegistrationRequest.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/RegistrationRequest.java index 6e79b5e0..9cf83623 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/RegistrationRequest.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/RegistrationRequest.java @@ -1,36 +1,36 @@ -package com.commafeed.frontend.model.request; - -import java.io.Serializable; - -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.Size; - -import com.commafeed.security.password.ValidPassword; - -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Data -@Schema -public class RegistrationRequest implements Serializable { - - @Schema(description = "username, between 3 and 32 characters", requiredMode = RequiredMode.REQUIRED) - @NotEmpty - @Size(min = 3, max = 32) - private String name; - - @Schema(description = "password, minimum 6 characters", requiredMode = RequiredMode.REQUIRED) - @NotEmpty - @ValidPassword - private String password; - - @Schema(description = "email address for password recovery", requiredMode = RequiredMode.REQUIRED) - @Email - @NotEmpty - @Size(max = 255) - private String email; - -} +package com.commafeed.frontend.model.request; + +import java.io.Serializable; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +import com.commafeed.security.password.ValidPassword; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Data +@Schema +public class RegistrationRequest implements Serializable { + + @Schema(description = "username, between 3 and 32 characters", requiredMode = RequiredMode.REQUIRED) + @NotEmpty + @Size(min = 3, max = 32) + private String name; + + @Schema(description = "password, minimum 6 characters", requiredMode = RequiredMode.REQUIRED) + @NotEmpty + @ValidPassword + private String password; + + @Schema(description = "email address for password recovery", requiredMode = RequiredMode.REQUIRED) + @Email + @NotEmpty + @Size(max = 255) + private String email; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/StarRequest.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/StarRequest.java index 5a6295b9..f32a119d 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/StarRequest.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/StarRequest.java @@ -1,28 +1,28 @@ -package com.commafeed.frontend.model.request; - -import java.io.Serializable; - -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.Size; - -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema(description = "Star Request") -@Data -public class StarRequest implements Serializable { - - @Schema(description = "id", requiredMode = RequiredMode.REQUIRED) - @NotEmpty - @Size(max = 128) - private String id; - - @Schema(description = "feed id", requiredMode = RequiredMode.REQUIRED) - private Long feedId; - - @Schema(description = "starred or not", requiredMode = RequiredMode.REQUIRED) - private boolean starred; - -} +package com.commafeed.frontend.model.request; + +import java.io.Serializable; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema(description = "Star Request") +@Data +public class StarRequest implements Serializable { + + @Schema(description = "id", requiredMode = RequiredMode.REQUIRED) + @NotEmpty + @Size(max = 128) + private String id; + + @Schema(description = "feed id", requiredMode = RequiredMode.REQUIRED) + private Long feedId; + + @Schema(description = "starred or not", requiredMode = RequiredMode.REQUIRED) + private boolean starred; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/SubscribeRequest.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/SubscribeRequest.java index 993982cc..f2056bff 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/SubscribeRequest.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/SubscribeRequest.java @@ -1,31 +1,31 @@ -package com.commafeed.frontend.model.request; - -import java.io.Serializable; - -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.Size; - -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema(description = "Subscription request") -@Data -public class SubscribeRequest implements Serializable { - - @Schema(description = "url of the feed", requiredMode = RequiredMode.REQUIRED) - @NotEmpty - @Size(max = 4096) - private String url; - - @Schema(description = "name of the feed for the user", requiredMode = RequiredMode.REQUIRED) - @NotEmpty - @Size(max = 128) - private String title; - - @Schema(description = "id of the user category to place the feed in") - @Size(max = 128) - private String categoryId; - -} +package com.commafeed.frontend.model.request; + +import java.io.Serializable; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema(description = "Subscription request") +@Data +public class SubscribeRequest implements Serializable { + + @Schema(description = "url of the feed", requiredMode = RequiredMode.REQUIRED) + @NotEmpty + @Size(max = 4096) + private String url; + + @Schema(description = "name of the feed for the user", requiredMode = RequiredMode.REQUIRED) + @NotEmpty + @Size(max = 128) + private String title; + + @Schema(description = "id of the user category to place the feed in") + @Size(max = 128) + private String categoryId; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/TagRequest.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/TagRequest.java index fe4e4680..b04bf3d6 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/TagRequest.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/TagRequest.java @@ -1,21 +1,21 @@ -package com.commafeed.frontend.model.request; - -import java.io.Serializable; -import java.util.List; - -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; -import lombok.Data; - -@SuppressWarnings("serial") -@Schema(description = "Tag Request") -@Data -public class TagRequest implements Serializable { - - @Schema(description = "entry id", requiredMode = RequiredMode.REQUIRED) - private Long entryId; - - @Schema(description = "tags", requiredMode = RequiredMode.REQUIRED) - private List tags; - -} +package com.commafeed.frontend.model.request; + +import java.io.Serializable; +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; +import lombok.Data; + +@SuppressWarnings("serial") +@Schema(description = "Tag Request") +@Data +public class TagRequest implements Serializable { + + @Schema(description = "entry id", requiredMode = RequiredMode.REQUIRED) + private Long entryId; + + @Schema(description = "tags", requiredMode = RequiredMode.REQUIRED) + private List tags; + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/resource/AdminREST.java b/commafeed-server/src/main/java/com/commafeed/frontend/resource/AdminREST.java index 4d9d655f..88cb5a4e 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/resource/AdminREST.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/resource/AdminREST.java @@ -1,199 +1,199 @@ -package com.commafeed.frontend.resource; - -import java.util.HashMap; -import java.util.Map; -import java.util.Set; - -import jakarta.annotation.security.RolesAllowed; -import jakarta.inject.Singleton; -import jakarta.transaction.Transactional; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.PathParam; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.Response.Status; - -import org.apache.commons.lang3.StringUtils; - -import com.codahale.metrics.MetricRegistry; -import com.commafeed.CommaFeedApplication; -import com.commafeed.backend.dao.UserDAO; -import com.commafeed.backend.dao.UserRoleDAO; -import com.commafeed.backend.model.User; -import com.commafeed.backend.model.UserRole; -import com.commafeed.backend.model.UserRole.Role; -import com.commafeed.backend.service.PasswordEncryptionService; -import com.commafeed.backend.service.UserService; -import com.commafeed.frontend.model.UserModel; -import com.commafeed.frontend.model.request.AdminSaveUserRequest; -import com.commafeed.frontend.model.request.IDRequest; -import com.commafeed.security.AuthenticationContext; -import com.commafeed.security.Roles; -import com.google.common.base.Preconditions; -import com.google.common.collect.Sets; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.ArraySchema; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; - -@Path("/rest/admin") -@RolesAllowed(Roles.ADMIN) -@Produces(MediaType.APPLICATION_JSON) -@Consumes(MediaType.APPLICATION_JSON) -@RequiredArgsConstructor -@Singleton -@Tag(name = "Admin") -public class AdminREST { - - private final AuthenticationContext authenticationContext; - private final UserDAO userDAO; - private final UserRoleDAO userRoleDAO; - private final UserService userService; - private final PasswordEncryptionService encryptionService; - private final MetricRegistry metrics; - - @Path("/user/save") - @POST - @Transactional - @Operation( - summary = "Save or update a user", - description = "Save or update a user. If the id is not specified, a new user will be created") - public Response adminSaveUser(@Parameter(required = true) AdminSaveUserRequest req) { - Preconditions.checkNotNull(req); - Preconditions.checkNotNull(req.getName()); - - Long id = req.getId(); - if (id == null) { - Preconditions.checkNotNull(req.getPassword()); - - Set roles = Sets.newHashSet(Role.USER); - if (req.isAdmin()) { - roles.add(Role.ADMIN); - } - try { - userService.register(req.getName(), req.getPassword(), req.getEmail(), roles, true); - } catch (Exception e) { - return Response.status(Status.CONFLICT).entity(e.getMessage()).build(); - } - } else { - User user = authenticationContext.getCurrentUser(); - if (req.getId().equals(user.getId()) && !req.isEnabled()) { - return Response.status(Status.FORBIDDEN).entity("You cannot disable your own account.").build(); - } - - User u = userDAO.findById(id); - u.setName(req.getName()); - if (StringUtils.isNotBlank(req.getPassword())) { - u.setPassword(encryptionService.getEncryptedPassword(req.getPassword(), u.getSalt())); - } - u.setEmail(req.getEmail()); - u.setDisabled(!req.isEnabled()); - userDAO.saveOrUpdate(u); - - Set roles = userRoleDAO.findRoles(u); - if (req.isAdmin() && !roles.contains(Role.ADMIN)) { - userRoleDAO.saveOrUpdate(new UserRole(u, Role.ADMIN)); - } else if (!req.isAdmin() && roles.contains(Role.ADMIN)) { - if (CommaFeedApplication.USERNAME_ADMIN.equals(u.getName())) { - return Response.status(Status.FORBIDDEN).entity("You cannot remove the admin role from the admin user.").build(); - } - for (UserRole userRole : userRoleDAO.findAll(u)) { - if (userRole.getRole() == Role.ADMIN) { - userRoleDAO.delete(userRole); - } - } - } - - } - return Response.ok().build(); - - } - - @Path("/user/get/{id}") - @GET - @Transactional - @Operation( - summary = "Get user information", - description = "Get user information", - responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = UserModel.class))) }) - public Response adminGetUser(@Parameter(description = "user id", required = true) @PathParam("id") Long id) { - Preconditions.checkNotNull(id); - User u = userDAO.findById(id); - UserModel userModel = new UserModel(); - userModel.setId(u.getId()); - userModel.setName(u.getName()); - userModel.setEmail(u.getEmail()); - userModel.setEnabled(!u.isDisabled()); - userModel.setAdmin(userRoleDAO.findAll(u).stream().anyMatch(r -> r.getRole() == Role.ADMIN)); - return Response.ok(userModel).build(); - } - - @Path("/user/getAll") - @GET - @Transactional - @Operation( - summary = "Get all users", - description = "Get all users", - responses = { @ApiResponse(content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserModel.class)))) }) - public Response adminGetUsers() { - Map users = new HashMap<>(); - for (UserRole role : userRoleDAO.findAll()) { - User u = role.getUser(); - Long key = u.getId(); - UserModel userModel = users.get(key); - if (userModel == null) { - userModel = new UserModel(); - userModel.setId(u.getId()); - userModel.setName(u.getName()); - userModel.setEmail(u.getEmail()); - userModel.setEnabled(!u.isDisabled()); - userModel.setCreated(u.getCreated()); - userModel.setLastLogin(u.getLastLogin()); - users.put(key, userModel); - } - if (role.getRole() == Role.ADMIN) { - userModel.setAdmin(true); - } - } - return Response.ok(users.values()).build(); - } - - @Path("/user/delete") - @POST - @Transactional - @Operation(summary = "Delete a user", description = "Delete a user, and all his subscriptions") - public Response adminDeleteUser(@Parameter(required = true) IDRequest req) { - Preconditions.checkNotNull(req); - Preconditions.checkNotNull(req.getId()); - - User u = userDAO.findById(req.getId()); - if (u == null) { - return Response.status(Status.NOT_FOUND).build(); - } - - User user = authenticationContext.getCurrentUser(); - if (user.getId().equals(u.getId())) { - return Response.status(Status.FORBIDDEN).entity("You cannot delete your own user.").build(); - } - userService.unregister(u); - return Response.ok().build(); - } - - @Path("/metrics") - @GET - @Transactional - @Operation(summary = "Retrieve server metrics") - public Response getMetrics() { - return Response.ok(metrics).build(); - } - -} +package com.commafeed.frontend.resource; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Singleton; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; + +import org.apache.commons.lang3.StringUtils; + +import com.codahale.metrics.MetricRegistry; +import com.commafeed.CommaFeedApplication; +import com.commafeed.backend.dao.UserDAO; +import com.commafeed.backend.dao.UserRoleDAO; +import com.commafeed.backend.model.User; +import com.commafeed.backend.model.UserRole; +import com.commafeed.backend.model.UserRole.Role; +import com.commafeed.backend.service.PasswordEncryptionService; +import com.commafeed.backend.service.UserService; +import com.commafeed.frontend.model.UserModel; +import com.commafeed.frontend.model.request.AdminSaveUserRequest; +import com.commafeed.frontend.model.request.IDRequest; +import com.commafeed.security.AuthenticationContext; +import com.commafeed.security.Roles; +import com.google.common.base.Preconditions; +import com.google.common.collect.Sets; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Path("/rest/admin") +@RolesAllowed(Roles.ADMIN) +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@RequiredArgsConstructor +@Singleton +@Tag(name = "Admin") +public class AdminREST { + + private final AuthenticationContext authenticationContext; + private final UserDAO userDAO; + private final UserRoleDAO userRoleDAO; + private final UserService userService; + private final PasswordEncryptionService encryptionService; + private final MetricRegistry metrics; + + @Path("/user/save") + @POST + @Transactional + @Operation( + summary = "Save or update a user", + description = "Save or update a user. If the id is not specified, a new user will be created") + public Response adminSaveUser(@Parameter(required = true) AdminSaveUserRequest req) { + Preconditions.checkNotNull(req); + Preconditions.checkNotNull(req.getName()); + + Long id = req.getId(); + if (id == null) { + Preconditions.checkNotNull(req.getPassword()); + + Set roles = Sets.newHashSet(Role.USER); + if (req.isAdmin()) { + roles.add(Role.ADMIN); + } + try { + userService.register(req.getName(), req.getPassword(), req.getEmail(), roles, true); + } catch (Exception e) { + return Response.status(Status.CONFLICT).entity(e.getMessage()).build(); + } + } else { + User user = authenticationContext.getCurrentUser(); + if (req.getId().equals(user.getId()) && !req.isEnabled()) { + return Response.status(Status.FORBIDDEN).entity("You cannot disable your own account.").build(); + } + + User u = userDAO.findById(id); + u.setName(req.getName()); + if (StringUtils.isNotBlank(req.getPassword())) { + u.setPassword(encryptionService.getEncryptedPassword(req.getPassword(), u.getSalt())); + } + u.setEmail(req.getEmail()); + u.setDisabled(!req.isEnabled()); + userDAO.saveOrUpdate(u); + + Set roles = userRoleDAO.findRoles(u); + if (req.isAdmin() && !roles.contains(Role.ADMIN)) { + userRoleDAO.saveOrUpdate(new UserRole(u, Role.ADMIN)); + } else if (!req.isAdmin() && roles.contains(Role.ADMIN)) { + if (CommaFeedApplication.USERNAME_ADMIN.equals(u.getName())) { + return Response.status(Status.FORBIDDEN).entity("You cannot remove the admin role from the admin user.").build(); + } + for (UserRole userRole : userRoleDAO.findAll(u)) { + if (userRole.getRole() == Role.ADMIN) { + userRoleDAO.delete(userRole); + } + } + } + + } + return Response.ok().build(); + + } + + @Path("/user/get/{id}") + @GET + @Transactional + @Operation( + summary = "Get user information", + description = "Get user information", + responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = UserModel.class))) }) + public Response adminGetUser(@Parameter(description = "user id", required = true) @PathParam("id") Long id) { + Preconditions.checkNotNull(id); + User u = userDAO.findById(id); + UserModel userModel = new UserModel(); + userModel.setId(u.getId()); + userModel.setName(u.getName()); + userModel.setEmail(u.getEmail()); + userModel.setEnabled(!u.isDisabled()); + userModel.setAdmin(userRoleDAO.findAll(u).stream().anyMatch(r -> r.getRole() == Role.ADMIN)); + return Response.ok(userModel).build(); + } + + @Path("/user/getAll") + @GET + @Transactional + @Operation( + summary = "Get all users", + description = "Get all users", + responses = { @ApiResponse(content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserModel.class)))) }) + public Response adminGetUsers() { + Map users = new HashMap<>(); + for (UserRole role : userRoleDAO.findAll()) { + User u = role.getUser(); + Long key = u.getId(); + UserModel userModel = users.get(key); + if (userModel == null) { + userModel = new UserModel(); + userModel.setId(u.getId()); + userModel.setName(u.getName()); + userModel.setEmail(u.getEmail()); + userModel.setEnabled(!u.isDisabled()); + userModel.setCreated(u.getCreated()); + userModel.setLastLogin(u.getLastLogin()); + users.put(key, userModel); + } + if (role.getRole() == Role.ADMIN) { + userModel.setAdmin(true); + } + } + return Response.ok(users.values()).build(); + } + + @Path("/user/delete") + @POST + @Transactional + @Operation(summary = "Delete a user", description = "Delete a user, and all his subscriptions") + public Response adminDeleteUser(@Parameter(required = true) IDRequest req) { + Preconditions.checkNotNull(req); + Preconditions.checkNotNull(req.getId()); + + User u = userDAO.findById(req.getId()); + if (u == null) { + return Response.status(Status.NOT_FOUND).build(); + } + + User user = authenticationContext.getCurrentUser(); + if (user.getId().equals(u.getId())) { + return Response.status(Status.FORBIDDEN).entity("You cannot delete your own user.").build(); + } + userService.unregister(u); + return Response.ok().build(); + } + + @Path("/metrics") + @GET + @Transactional + @Operation(summary = "Retrieve server metrics") + public Response getMetrics() { + return Response.ok(metrics).build(); + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/resource/CategoryREST.java b/commafeed-server/src/main/java/com/commafeed/frontend/resource/CategoryREST.java index 7a8d6c1b..06020790 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/resource/CategoryREST.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/resource/CategoryREST.java @@ -1,465 +1,465 @@ -package com.commafeed.frontend.resource; - -import java.io.StringWriter; -import java.time.Instant; -import java.util.Arrays; -import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; - -import jakarta.annotation.security.RolesAllowed; -import jakarta.inject.Singleton; -import jakarta.transaction.Transactional; -import jakarta.validation.Valid; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.DefaultValue; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.QueryParam; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.Response.Status; -import jakarta.ws.rs.core.UriInfo; - -import org.apache.commons.collections4.CollectionUtils; -import org.apache.commons.lang3.ObjectUtils; -import org.apache.commons.lang3.StringUtils; - -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.dao.FeedCategoryDAO; -import com.commafeed.backend.dao.FeedEntryStatusDAO; -import com.commafeed.backend.dao.FeedSubscriptionDAO; -import com.commafeed.backend.feed.FeedEntryKeyword; -import com.commafeed.backend.feed.FeedUtils; -import com.commafeed.backend.model.FeedCategory; -import com.commafeed.backend.model.FeedEntryStatus; -import com.commafeed.backend.model.FeedSubscription; -import com.commafeed.backend.model.User; -import com.commafeed.backend.model.UserSettings.ReadingMode; -import com.commafeed.backend.model.UserSettings.ReadingOrder; -import com.commafeed.backend.service.FeedEntryService; -import com.commafeed.backend.service.FeedSubscriptionService; -import com.commafeed.frontend.model.Category; -import com.commafeed.frontend.model.Entries; -import com.commafeed.frontend.model.Entry; -import com.commafeed.frontend.model.Subscription; -import com.commafeed.frontend.model.UnreadCount; -import com.commafeed.frontend.model.request.AddCategoryRequest; -import com.commafeed.frontend.model.request.CategoryModificationRequest; -import com.commafeed.frontend.model.request.CollapseRequest; -import com.commafeed.frontend.model.request.IDRequest; -import com.commafeed.frontend.model.request.MarkRequest; -import com.commafeed.security.AuthenticationContext; -import com.commafeed.security.Roles; -import com.google.common.base.Preconditions; -import com.google.common.collect.Lists; -import com.rometools.rome.feed.synd.SyndFeed; -import com.rometools.rome.feed.synd.SyndFeedImpl; -import com.rometools.rome.io.SyndFeedOutput; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.ArraySchema; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Path("/rest/category") -@RolesAllowed(Roles.USER) -@Slf4j -@Produces(MediaType.APPLICATION_JSON) -@Consumes(MediaType.APPLICATION_JSON) -@RequiredArgsConstructor -@Singleton -@Tag(name = "Feed categories") -public class CategoryREST { - - public static final String ALL = "all"; - public static final String STARRED = "starred"; - - private final AuthenticationContext authenticationContext; - private final FeedCategoryDAO feedCategoryDAO; - private final FeedEntryStatusDAO feedEntryStatusDAO; - private final FeedSubscriptionDAO feedSubscriptionDAO; - private final FeedEntryService feedEntryService; - private final FeedSubscriptionService feedSubscriptionService; - private final CommaFeedConfiguration config; - private final UriInfo uri; - - @Path("/entries") - @GET - @Transactional - @Operation( - summary = "Get category entries", - description = "Get a list of category entries", - responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Entries.class))) }) - public Response getCategoryEntries( - @Parameter(description = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id") String id, - @Parameter( - description = "all entries or only unread ones", - required = true) @DefaultValue("unread") @QueryParam("readType") ReadingMode readType, - @Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan, - @Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset, - @Parameter(description = "limit for paging, default 20, maximum 1000") @DefaultValue("20") @QueryParam("limit") int limit, - @Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order, - @Parameter( - description = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords, - @Parameter( - description = "comma-separated list of excluded subscription ids") @QueryParam("excludedSubscriptionIds") String excludedSubscriptionIds, - @Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag") String tag) { - - Preconditions.checkNotNull(readType); - - keywords = StringUtils.trimToNull(keywords); - Preconditions.checkArgument(keywords == null || StringUtils.length(keywords) >= 3); - List entryKeywords = FeedEntryKeyword.fromQueryString(keywords); - - limit = Math.min(limit, 1000); - limit = Math.max(0, limit); - - Entries entries = new Entries(); - entries.setOffset(offset); - entries.setLimit(limit); - boolean unreadOnly = readType == ReadingMode.unread; - if (StringUtils.isBlank(id)) { - id = ALL; - } - - Instant newerThanDate = newerThan == null ? null : Instant.ofEpochMilli(newerThan); - - List excludedIds = null; - if (StringUtils.isNotEmpty(excludedSubscriptionIds)) { - excludedIds = Arrays.stream(excludedSubscriptionIds.split(",")).map(Long::valueOf).toList(); - } - - User user = authenticationContext.getCurrentUser(); - if (ALL.equals(id)) { - entries.setName(Optional.ofNullable(tag).orElse("All")); - - List subs = feedSubscriptionDAO.findAll(user); - removeExcludedSubscriptions(subs, excludedIds); - List list = feedEntryStatusDAO.findBySubscriptions(user, subs, unreadOnly, entryKeywords, newerThanDate, - offset, limit + 1, order, true, tag, null, null); - - for (FeedEntryStatus status : list) { - entries.getEntries().add(Entry.build(status, config.imageProxyEnabled())); - } - - } else if (STARRED.equals(id)) { - entries.setName("Starred"); - List starred = feedEntryStatusDAO.findStarred(user, newerThanDate, offset, limit + 1, order, true); - for (FeedEntryStatus status : starred) { - entries.getEntries().add(Entry.build(status, config.imageProxyEnabled())); - } - } else { - FeedCategory parent = feedCategoryDAO.findById(user, Long.valueOf(id)); - if (parent != null) { - List categories = feedCategoryDAO.findAllChildrenCategories(user, parent); - List subs = feedSubscriptionDAO.findByCategories(user, categories); - removeExcludedSubscriptions(subs, excludedIds); - List list = feedEntryStatusDAO.findBySubscriptions(user, subs, unreadOnly, entryKeywords, newerThanDate, - offset, limit + 1, order, true, tag, null, null); - - for (FeedEntryStatus status : list) { - entries.getEntries().add(Entry.build(status, config.imageProxyEnabled())); - } - entries.setName(parent.getName()); - } else { - return Response.status(Status.NOT_FOUND).entity("category not found").build(); - } - } - - boolean hasMore = entries.getEntries().size() > limit; - if (hasMore) { - entries.setHasMore(true); - entries.getEntries().remove(entries.getEntries().size() - 1); - } - - entries.setTimestamp(System.currentTimeMillis()); - entries.setIgnoredReadStatus(STARRED.equals(id) || keywords != null || tag != null); - FeedUtils.removeUnwantedFromSearch(entries.getEntries(), entryKeywords); - return Response.ok(entries).build(); - } - - @Path("/entriesAsFeed") - @GET - @Transactional - @Operation(summary = "Get category entries as feed", description = "Get a feed of category entries") - @Produces(MediaType.APPLICATION_XML) - public Response getCategoryEntriesAsFeed( - @Parameter(description = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id") String id, - @Parameter( - description = "all entries or only unread ones", - required = true) @DefaultValue("all") @QueryParam("readType") ReadingMode readType, - @Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan, - @Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset, - @Parameter(description = "limit for paging, default 20, maximum 1000") @DefaultValue("20") @QueryParam("limit") int limit, - @Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order, - @Parameter( - description = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords, - @Parameter( - description = "comma-separated list of excluded subscription ids") @QueryParam("excludedSubscriptionIds") String excludedSubscriptionIds, - @Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag") String tag) { - - Response response = getCategoryEntries(id, readType, newerThan, offset, limit, order, keywords, excludedSubscriptionIds, tag); - if (response.getStatus() != Status.OK.getStatusCode()) { - return response; - } - Entries entries = (Entries) response.getEntity(); - - SyndFeed feed = new SyndFeedImpl(); - feed.setFeedType("rss_2.0"); - feed.setTitle("CommaFeed - " + entries.getName()); - feed.setDescription("CommaFeed - " + entries.getName()); - feed.setLink(uri.getBaseUri().toString()); - feed.setEntries(entries.getEntries().stream().map(Entry::asRss).toList()); - - SyndFeedOutput output = new SyndFeedOutput(); - StringWriter writer = new StringWriter(); - try { - output.output(feed, writer); - } catch (Exception e) { - writer.write("Could not get feed information"); - log.error(e.getMessage(), e); - } - return Response.ok(writer.toString()).build(); - } - - @Path("/mark") - @POST - @Transactional - @Operation(summary = "Mark category entries", description = "Mark feed entries of this category as read") - public Response markCategoryEntries(@Valid @Parameter(description = "category id, or 'all'", required = true) MarkRequest req) { - Preconditions.checkNotNull(req); - Preconditions.checkNotNull(req.getId()); - - Instant olderThan = req.getOlderThan() == null ? null : Instant.ofEpochMilli(req.getOlderThan()); - Instant insertedBefore = req.getInsertedBefore() == null ? null : Instant.ofEpochMilli(req.getInsertedBefore()); - String keywords = req.getKeywords(); - List entryKeywords = FeedEntryKeyword.fromQueryString(keywords); - - User user = authenticationContext.getCurrentUser(); - if (ALL.equals(req.getId())) { - List subs = feedSubscriptionDAO.findAll(user); - removeExcludedSubscriptions(subs, req.getExcludedSubscriptions()); - feedEntryService.markSubscriptionEntries(user, subs, olderThan, insertedBefore, entryKeywords); - } else if (STARRED.equals(req.getId())) { - feedEntryService.markStarredEntries(user, olderThan, insertedBefore); - } else { - FeedCategory parent = feedCategoryDAO.findById(user, Long.valueOf(req.getId())); - List categories = feedCategoryDAO.findAllChildrenCategories(user, parent); - List subs = feedSubscriptionDAO.findByCategories(user, categories); - removeExcludedSubscriptions(subs, req.getExcludedSubscriptions()); - feedEntryService.markSubscriptionEntries(user, subs, olderThan, insertedBefore, entryKeywords); - } - return Response.ok().build(); - } - - private void removeExcludedSubscriptions(List subs, List excludedIds) { - if (CollectionUtils.isNotEmpty(excludedIds)) { - subs.removeIf(sub -> excludedIds.contains(sub.getId())); - } - } - - @Path("/add") - @POST - @Transactional - @Operation( - summary = "Add a category", - description = "Add a new feed category", - responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Long.class))) }) - public Response addCategory(@Valid @Parameter(required = true) AddCategoryRequest req) { - Preconditions.checkNotNull(req); - Preconditions.checkNotNull(req.getName()); - - User user = authenticationContext.getCurrentUser(); - - FeedCategory cat = new FeedCategory(); - cat.setName(req.getName()); - cat.setUser(user); - cat.setPosition(0); - String parentId = req.getParentId(); - if (parentId != null && !ALL.equals(parentId)) { - FeedCategory parent = new FeedCategory(); - parent.setId(Long.valueOf(parentId)); - cat.setParent(parent); - } - feedCategoryDAO.saveOrUpdate(cat); - return Response.ok(cat.getId()).build(); - } - - @POST - @Path("/delete") - @Transactional - @Operation(summary = "Delete a category", description = "Delete an existing feed category") - public Response deleteCategory(@Parameter(required = true) IDRequest req) { - - Preconditions.checkNotNull(req); - Preconditions.checkNotNull(req.getId()); - - User user = authenticationContext.getCurrentUser(); - FeedCategory cat = feedCategoryDAO.findById(user, req.getId()); - if (cat != null) { - List subs = feedSubscriptionDAO.findByCategory(user, cat); - for (FeedSubscription sub : subs) { - sub.setCategory(null); - } - feedSubscriptionDAO.saveOrUpdate(subs); - List categories = feedCategoryDAO.findAllChildrenCategories(user, cat); - for (FeedCategory child : categories) { - if (!child.getId().equals(cat.getId()) && child.getParent().getId().equals(cat.getId())) { - child.setParent(null); - } - } - feedCategoryDAO.saveOrUpdate(categories); - - feedCategoryDAO.delete(cat); - return Response.ok().build(); - } else { - return Response.status(Status.NOT_FOUND).build(); - } - } - - @POST - @Path("/modify") - @Transactional - @Operation(summary = "Rename a category", description = "Rename an existing feed category") - public Response modifyCategory(@Valid @Parameter(required = true) CategoryModificationRequest req) { - Preconditions.checkNotNull(req); - Preconditions.checkNotNull(req.getId()); - - User user = authenticationContext.getCurrentUser(); - FeedCategory category = feedCategoryDAO.findById(user, req.getId()); - - if (StringUtils.isNotBlank(req.getName())) { - category.setName(req.getName()); - } - - FeedCategory parent = null; - if (req.getParentId() != null && !CategoryREST.ALL.equals(req.getParentId()) - && !StringUtils.equals(req.getParentId(), String.valueOf(req.getId()))) { - parent = feedCategoryDAO.findById(user, Long.valueOf(req.getParentId())); - } - category.setParent(parent); - - if (req.getPosition() != null) { - List categories = feedCategoryDAO.findByParent(user, parent); - categories.sort((o1, o2) -> ObjectUtils.compare(o1.getPosition(), o2.getPosition())); - - int existingIndex = -1; - for (int i = 0; i < categories.size(); i++) { - if (Objects.equals(categories.get(i).getId(), category.getId())) { - existingIndex = i; - } - } - if (existingIndex != -1) { - categories.remove(existingIndex); - } - - categories.add(Math.min(req.getPosition(), categories.size()), category); - for (int i = 0; i < categories.size(); i++) { - categories.get(i).setPosition(i); - } - feedCategoryDAO.saveOrUpdate(categories); - } else { - feedCategoryDAO.saveOrUpdate(category); - } - - feedCategoryDAO.saveOrUpdate(category); - return Response.ok().build(); - } - - @POST - @Path("/collapse") - @Transactional - @Operation(summary = "Collapse a category", description = "Save collapsed or expanded status for a category") - public Response collapseCategory(@Parameter(required = true) CollapseRequest req) { - Preconditions.checkNotNull(req); - Preconditions.checkNotNull(req.getId()); - - User user = authenticationContext.getCurrentUser(); - FeedCategory category = feedCategoryDAO.findById(user, req.getId()); - if (category == null) { - return Response.status(Status.NOT_FOUND).build(); - } - category.setCollapsed(req.isCollapse()); - feedCategoryDAO.saveOrUpdate(category); - return Response.ok().build(); - } - - @GET - @Path("/unreadCount") - @Transactional - @Operation( - summary = "Get unread count for feed subscriptions", - responses = { @ApiResponse(content = @Content(array = @ArraySchema(schema = @Schema(implementation = UnreadCount.class)))) }) - public Response getUnreadCount() { - User user = authenticationContext.getCurrentUser(); - Map unreadCount = feedSubscriptionService.getUnreadCount(user); - return Response.ok(Lists.newArrayList(unreadCount.values())).build(); - } - - @GET - @Path("/get") - @Transactional - @Operation( - summary = "Get root category", - description = "Get all categories and subscriptions of the user", - responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Category.class))) }) - public Response getRootCategory() { - User user = authenticationContext.getCurrentUser(); - - List categories = feedCategoryDAO.findAll(user); - List subscriptions = feedSubscriptionDAO.findAll(user); - Map unreadCount = feedSubscriptionService.getUnreadCount(user); - - Category root = buildCategory(null, categories, subscriptions, unreadCount); - root.setId("all"); - root.setName("All"); - - return Response.ok(root).build(); - } - - private Category buildCategory(Long id, List categories, List subscriptions, - Map unreadCount) { - Category category = new Category(); - category.setId(String.valueOf(id)); - category.setExpanded(true); - - for (FeedCategory c : categories) { - if (id == null && c.getParent() == null || c.getParent() != null && Objects.equals(c.getParent().getId(), id)) { - Category child = buildCategory(c.getId(), categories, subscriptions, unreadCount); - child.setId(String.valueOf(c.getId())); - child.setName(c.getName()); - child.setPosition(c.getPosition()); - if (c.getParent() != null && c.getParent().getId() != null) { - child.setParentId(String.valueOf(c.getParent().getId())); - child.setParentName(c.getParent().getName()); - } - child.setExpanded(!c.isCollapsed()); - category.getChildren().add(child); - } - } - category.getChildren().sort(Comparator.comparing(Category::getPosition).thenComparing(Category::getName)); - - for (FeedSubscription subscription : subscriptions) { - if (id == null && subscription.getCategory() == null - || subscription.getCategory() != null && Objects.equals(subscription.getCategory().getId(), id)) { - UnreadCount uc = unreadCount.get(subscription.getId()); - Subscription sub = Subscription.build(subscription, uc); - category.getFeeds().add(sub); - } - } - category.getFeeds().sort(Comparator.comparing(Subscription::getPosition).thenComparing(Subscription::getName)); - - return category; - } - -} +package com.commafeed.frontend.resource; + +import java.io.StringWriter; +import java.time.Instant; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Singleton; +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.core.UriInfo; + +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; + +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.backend.dao.FeedCategoryDAO; +import com.commafeed.backend.dao.FeedEntryStatusDAO; +import com.commafeed.backend.dao.FeedSubscriptionDAO; +import com.commafeed.backend.feed.FeedEntryKeyword; +import com.commafeed.backend.feed.FeedUtils; +import com.commafeed.backend.model.FeedCategory; +import com.commafeed.backend.model.FeedEntryStatus; +import com.commafeed.backend.model.FeedSubscription; +import com.commafeed.backend.model.User; +import com.commafeed.backend.model.UserSettings.ReadingMode; +import com.commafeed.backend.model.UserSettings.ReadingOrder; +import com.commafeed.backend.service.FeedEntryService; +import com.commafeed.backend.service.FeedSubscriptionService; +import com.commafeed.frontend.model.Category; +import com.commafeed.frontend.model.Entries; +import com.commafeed.frontend.model.Entry; +import com.commafeed.frontend.model.Subscription; +import com.commafeed.frontend.model.UnreadCount; +import com.commafeed.frontend.model.request.AddCategoryRequest; +import com.commafeed.frontend.model.request.CategoryModificationRequest; +import com.commafeed.frontend.model.request.CollapseRequest; +import com.commafeed.frontend.model.request.IDRequest; +import com.commafeed.frontend.model.request.MarkRequest; +import com.commafeed.security.AuthenticationContext; +import com.commafeed.security.Roles; +import com.google.common.base.Preconditions; +import com.google.common.collect.Lists; +import com.rometools.rome.feed.synd.SyndFeed; +import com.rometools.rome.feed.synd.SyndFeedImpl; +import com.rometools.rome.io.SyndFeedOutput; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Path("/rest/category") +@RolesAllowed(Roles.USER) +@Slf4j +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@RequiredArgsConstructor +@Singleton +@Tag(name = "Feed categories") +public class CategoryREST { + + public static final String ALL = "all"; + public static final String STARRED = "starred"; + + private final AuthenticationContext authenticationContext; + private final FeedCategoryDAO feedCategoryDAO; + private final FeedEntryStatusDAO feedEntryStatusDAO; + private final FeedSubscriptionDAO feedSubscriptionDAO; + private final FeedEntryService feedEntryService; + private final FeedSubscriptionService feedSubscriptionService; + private final CommaFeedConfiguration config; + private final UriInfo uri; + + @Path("/entries") + @GET + @Transactional + @Operation( + summary = "Get category entries", + description = "Get a list of category entries", + responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Entries.class))) }) + public Response getCategoryEntries( + @Parameter(description = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id") String id, + @Parameter( + description = "all entries or only unread ones", + required = true) @DefaultValue("unread") @QueryParam("readType") ReadingMode readType, + @Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan, + @Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset, + @Parameter(description = "limit for paging, default 20, maximum 1000") @DefaultValue("20") @QueryParam("limit") int limit, + @Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order, + @Parameter( + description = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords, + @Parameter( + description = "comma-separated list of excluded subscription ids") @QueryParam("excludedSubscriptionIds") String excludedSubscriptionIds, + @Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag") String tag) { + + Preconditions.checkNotNull(readType); + + keywords = StringUtils.trimToNull(keywords); + Preconditions.checkArgument(keywords == null || StringUtils.length(keywords) >= 3); + List entryKeywords = FeedEntryKeyword.fromQueryString(keywords); + + limit = Math.min(limit, 1000); + limit = Math.max(0, limit); + + Entries entries = new Entries(); + entries.setOffset(offset); + entries.setLimit(limit); + boolean unreadOnly = readType == ReadingMode.unread; + if (StringUtils.isBlank(id)) { + id = ALL; + } + + Instant newerThanDate = newerThan == null ? null : Instant.ofEpochMilli(newerThan); + + List excludedIds = null; + if (StringUtils.isNotEmpty(excludedSubscriptionIds)) { + excludedIds = Arrays.stream(excludedSubscriptionIds.split(",")).map(Long::valueOf).toList(); + } + + User user = authenticationContext.getCurrentUser(); + if (ALL.equals(id)) { + entries.setName(Optional.ofNullable(tag).orElse("All")); + + List subs = feedSubscriptionDAO.findAll(user); + removeExcludedSubscriptions(subs, excludedIds); + List list = feedEntryStatusDAO.findBySubscriptions(user, subs, unreadOnly, entryKeywords, newerThanDate, + offset, limit + 1, order, true, tag, null, null); + + for (FeedEntryStatus status : list) { + entries.getEntries().add(Entry.build(status, config.imageProxyEnabled())); + } + + } else if (STARRED.equals(id)) { + entries.setName("Starred"); + List starred = feedEntryStatusDAO.findStarred(user, newerThanDate, offset, limit + 1, order, true); + for (FeedEntryStatus status : starred) { + entries.getEntries().add(Entry.build(status, config.imageProxyEnabled())); + } + } else { + FeedCategory parent = feedCategoryDAO.findById(user, Long.valueOf(id)); + if (parent != null) { + List categories = feedCategoryDAO.findAllChildrenCategories(user, parent); + List subs = feedSubscriptionDAO.findByCategories(user, categories); + removeExcludedSubscriptions(subs, excludedIds); + List list = feedEntryStatusDAO.findBySubscriptions(user, subs, unreadOnly, entryKeywords, newerThanDate, + offset, limit + 1, order, true, tag, null, null); + + for (FeedEntryStatus status : list) { + entries.getEntries().add(Entry.build(status, config.imageProxyEnabled())); + } + entries.setName(parent.getName()); + } else { + return Response.status(Status.NOT_FOUND).entity("category not found").build(); + } + } + + boolean hasMore = entries.getEntries().size() > limit; + if (hasMore) { + entries.setHasMore(true); + entries.getEntries().remove(entries.getEntries().size() - 1); + } + + entries.setTimestamp(System.currentTimeMillis()); + entries.setIgnoredReadStatus(STARRED.equals(id) || keywords != null || tag != null); + FeedUtils.removeUnwantedFromSearch(entries.getEntries(), entryKeywords); + return Response.ok(entries).build(); + } + + @Path("/entriesAsFeed") + @GET + @Transactional + @Operation(summary = "Get category entries as feed", description = "Get a feed of category entries") + @Produces(MediaType.APPLICATION_XML) + public Response getCategoryEntriesAsFeed( + @Parameter(description = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id") String id, + @Parameter( + description = "all entries or only unread ones", + required = true) @DefaultValue("all") @QueryParam("readType") ReadingMode readType, + @Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan, + @Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset, + @Parameter(description = "limit for paging, default 20, maximum 1000") @DefaultValue("20") @QueryParam("limit") int limit, + @Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order, + @Parameter( + description = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords, + @Parameter( + description = "comma-separated list of excluded subscription ids") @QueryParam("excludedSubscriptionIds") String excludedSubscriptionIds, + @Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag") String tag) { + + Response response = getCategoryEntries(id, readType, newerThan, offset, limit, order, keywords, excludedSubscriptionIds, tag); + if (response.getStatus() != Status.OK.getStatusCode()) { + return response; + } + Entries entries = (Entries) response.getEntity(); + + SyndFeed feed = new SyndFeedImpl(); + feed.setFeedType("rss_2.0"); + feed.setTitle("CommaFeed - " + entries.getName()); + feed.setDescription("CommaFeed - " + entries.getName()); + feed.setLink(uri.getBaseUri().toString()); + feed.setEntries(entries.getEntries().stream().map(Entry::asRss).toList()); + + SyndFeedOutput output = new SyndFeedOutput(); + StringWriter writer = new StringWriter(); + try { + output.output(feed, writer); + } catch (Exception e) { + writer.write("Could not get feed information"); + log.error(e.getMessage(), e); + } + return Response.ok(writer.toString()).build(); + } + + @Path("/mark") + @POST + @Transactional + @Operation(summary = "Mark category entries", description = "Mark feed entries of this category as read") + public Response markCategoryEntries(@Valid @Parameter(description = "category id, or 'all'", required = true) MarkRequest req) { + Preconditions.checkNotNull(req); + Preconditions.checkNotNull(req.getId()); + + Instant olderThan = req.getOlderThan() == null ? null : Instant.ofEpochMilli(req.getOlderThan()); + Instant insertedBefore = req.getInsertedBefore() == null ? null : Instant.ofEpochMilli(req.getInsertedBefore()); + String keywords = req.getKeywords(); + List entryKeywords = FeedEntryKeyword.fromQueryString(keywords); + + User user = authenticationContext.getCurrentUser(); + if (ALL.equals(req.getId())) { + List subs = feedSubscriptionDAO.findAll(user); + removeExcludedSubscriptions(subs, req.getExcludedSubscriptions()); + feedEntryService.markSubscriptionEntries(user, subs, olderThan, insertedBefore, entryKeywords); + } else if (STARRED.equals(req.getId())) { + feedEntryService.markStarredEntries(user, olderThan, insertedBefore); + } else { + FeedCategory parent = feedCategoryDAO.findById(user, Long.valueOf(req.getId())); + List categories = feedCategoryDAO.findAllChildrenCategories(user, parent); + List subs = feedSubscriptionDAO.findByCategories(user, categories); + removeExcludedSubscriptions(subs, req.getExcludedSubscriptions()); + feedEntryService.markSubscriptionEntries(user, subs, olderThan, insertedBefore, entryKeywords); + } + return Response.ok().build(); + } + + private void removeExcludedSubscriptions(List subs, List excludedIds) { + if (CollectionUtils.isNotEmpty(excludedIds)) { + subs.removeIf(sub -> excludedIds.contains(sub.getId())); + } + } + + @Path("/add") + @POST + @Transactional + @Operation( + summary = "Add a category", + description = "Add a new feed category", + responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Long.class))) }) + public Response addCategory(@Valid @Parameter(required = true) AddCategoryRequest req) { + Preconditions.checkNotNull(req); + Preconditions.checkNotNull(req.getName()); + + User user = authenticationContext.getCurrentUser(); + + FeedCategory cat = new FeedCategory(); + cat.setName(req.getName()); + cat.setUser(user); + cat.setPosition(0); + String parentId = req.getParentId(); + if (parentId != null && !ALL.equals(parentId)) { + FeedCategory parent = new FeedCategory(); + parent.setId(Long.valueOf(parentId)); + cat.setParent(parent); + } + feedCategoryDAO.saveOrUpdate(cat); + return Response.ok(cat.getId()).build(); + } + + @POST + @Path("/delete") + @Transactional + @Operation(summary = "Delete a category", description = "Delete an existing feed category") + public Response deleteCategory(@Parameter(required = true) IDRequest req) { + + Preconditions.checkNotNull(req); + Preconditions.checkNotNull(req.getId()); + + User user = authenticationContext.getCurrentUser(); + FeedCategory cat = feedCategoryDAO.findById(user, req.getId()); + if (cat != null) { + List subs = feedSubscriptionDAO.findByCategory(user, cat); + for (FeedSubscription sub : subs) { + sub.setCategory(null); + } + feedSubscriptionDAO.saveOrUpdate(subs); + List categories = feedCategoryDAO.findAllChildrenCategories(user, cat); + for (FeedCategory child : categories) { + if (!child.getId().equals(cat.getId()) && child.getParent().getId().equals(cat.getId())) { + child.setParent(null); + } + } + feedCategoryDAO.saveOrUpdate(categories); + + feedCategoryDAO.delete(cat); + return Response.ok().build(); + } else { + return Response.status(Status.NOT_FOUND).build(); + } + } + + @POST + @Path("/modify") + @Transactional + @Operation(summary = "Rename a category", description = "Rename an existing feed category") + public Response modifyCategory(@Valid @Parameter(required = true) CategoryModificationRequest req) { + Preconditions.checkNotNull(req); + Preconditions.checkNotNull(req.getId()); + + User user = authenticationContext.getCurrentUser(); + FeedCategory category = feedCategoryDAO.findById(user, req.getId()); + + if (StringUtils.isNotBlank(req.getName())) { + category.setName(req.getName()); + } + + FeedCategory parent = null; + if (req.getParentId() != null && !CategoryREST.ALL.equals(req.getParentId()) + && !StringUtils.equals(req.getParentId(), String.valueOf(req.getId()))) { + parent = feedCategoryDAO.findById(user, Long.valueOf(req.getParentId())); + } + category.setParent(parent); + + if (req.getPosition() != null) { + List categories = feedCategoryDAO.findByParent(user, parent); + categories.sort((o1, o2) -> ObjectUtils.compare(o1.getPosition(), o2.getPosition())); + + int existingIndex = -1; + for (int i = 0; i < categories.size(); i++) { + if (Objects.equals(categories.get(i).getId(), category.getId())) { + existingIndex = i; + } + } + if (existingIndex != -1) { + categories.remove(existingIndex); + } + + categories.add(Math.min(req.getPosition(), categories.size()), category); + for (int i = 0; i < categories.size(); i++) { + categories.get(i).setPosition(i); + } + feedCategoryDAO.saveOrUpdate(categories); + } else { + feedCategoryDAO.saveOrUpdate(category); + } + + feedCategoryDAO.saveOrUpdate(category); + return Response.ok().build(); + } + + @POST + @Path("/collapse") + @Transactional + @Operation(summary = "Collapse a category", description = "Save collapsed or expanded status for a category") + public Response collapseCategory(@Parameter(required = true) CollapseRequest req) { + Preconditions.checkNotNull(req); + Preconditions.checkNotNull(req.getId()); + + User user = authenticationContext.getCurrentUser(); + FeedCategory category = feedCategoryDAO.findById(user, req.getId()); + if (category == null) { + return Response.status(Status.NOT_FOUND).build(); + } + category.setCollapsed(req.isCollapse()); + feedCategoryDAO.saveOrUpdate(category); + return Response.ok().build(); + } + + @GET + @Path("/unreadCount") + @Transactional + @Operation( + summary = "Get unread count for feed subscriptions", + responses = { @ApiResponse(content = @Content(array = @ArraySchema(schema = @Schema(implementation = UnreadCount.class)))) }) + public Response getUnreadCount() { + User user = authenticationContext.getCurrentUser(); + Map unreadCount = feedSubscriptionService.getUnreadCount(user); + return Response.ok(Lists.newArrayList(unreadCount.values())).build(); + } + + @GET + @Path("/get") + @Transactional + @Operation( + summary = "Get root category", + description = "Get all categories and subscriptions of the user", + responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Category.class))) }) + public Response getRootCategory() { + User user = authenticationContext.getCurrentUser(); + + List categories = feedCategoryDAO.findAll(user); + List subscriptions = feedSubscriptionDAO.findAll(user); + Map unreadCount = feedSubscriptionService.getUnreadCount(user); + + Category root = buildCategory(null, categories, subscriptions, unreadCount); + root.setId("all"); + root.setName("All"); + + return Response.ok(root).build(); + } + + private Category buildCategory(Long id, List categories, List subscriptions, + Map unreadCount) { + Category category = new Category(); + category.setId(String.valueOf(id)); + category.setExpanded(true); + + for (FeedCategory c : categories) { + if (id == null && c.getParent() == null || c.getParent() != null && Objects.equals(c.getParent().getId(), id)) { + Category child = buildCategory(c.getId(), categories, subscriptions, unreadCount); + child.setId(String.valueOf(c.getId())); + child.setName(c.getName()); + child.setPosition(c.getPosition()); + if (c.getParent() != null && c.getParent().getId() != null) { + child.setParentId(String.valueOf(c.getParent().getId())); + child.setParentName(c.getParent().getName()); + } + child.setExpanded(!c.isCollapsed()); + category.getChildren().add(child); + } + } + category.getChildren().sort(Comparator.comparing(Category::getPosition).thenComparing(Category::getName)); + + for (FeedSubscription subscription : subscriptions) { + if (id == null && subscription.getCategory() == null + || subscription.getCategory() != null && Objects.equals(subscription.getCategory().getId(), id)) { + UnreadCount uc = unreadCount.get(subscription.getId()); + Subscription sub = Subscription.build(subscription, uc); + category.getFeeds().add(sub); + } + } + category.getFeeds().sort(Comparator.comparing(Subscription::getPosition).thenComparing(Subscription::getName)); + + return category; + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/resource/EntryREST.java b/commafeed-server/src/main/java/com/commafeed/frontend/resource/EntryREST.java index 56d40a96..35544eed 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/resource/EntryREST.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/resource/EntryREST.java @@ -1,117 +1,117 @@ -package com.commafeed.frontend.resource; - -import java.util.List; - -import jakarta.annotation.security.RolesAllowed; -import jakarta.inject.Singleton; -import jakarta.transaction.Transactional; -import jakarta.validation.Valid; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; - -import com.commafeed.backend.dao.FeedEntryTagDAO; -import com.commafeed.backend.model.User; -import com.commafeed.backend.service.FeedEntryService; -import com.commafeed.backend.service.FeedEntryTagService; -import com.commafeed.frontend.model.request.MarkRequest; -import com.commafeed.frontend.model.request.MultipleMarkRequest; -import com.commafeed.frontend.model.request.StarRequest; -import com.commafeed.frontend.model.request.TagRequest; -import com.commafeed.security.AuthenticationContext; -import com.commafeed.security.Roles; -import com.google.common.base.Preconditions; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; - -@Path("/rest/entry") -@RolesAllowed(Roles.USER) -@Produces(MediaType.APPLICATION_JSON) -@Consumes(MediaType.APPLICATION_JSON) -@RequiredArgsConstructor -@Singleton -@Tag(name = "Feed entries") -public class EntryREST { - - private final AuthenticationContext authenticationContext; - private final FeedEntryTagDAO feedEntryTagDAO; - private final FeedEntryService feedEntryService; - private final FeedEntryTagService feedEntryTagService; - - @Path("/mark") - @POST - @Transactional - @Operation(summary = "Mark a feed entry", description = "Mark a feed entry as read/unread") - public Response markEntry(@Valid @Parameter(description = "Mark Request", required = true) MarkRequest req) { - Preconditions.checkNotNull(req); - Preconditions.checkNotNull(req.getId()); - - User user = authenticationContext.getCurrentUser(); - feedEntryService.markEntry(user, Long.valueOf(req.getId()), req.isRead()); - return Response.ok().build(); - } - - @Path("/markMultiple") - @POST - @Transactional - @Operation(summary = "Mark multiple feed entries", description = "Mark feed entries as read/unread") - public Response markEntries(@Valid @Parameter(description = "Multiple Mark Request", required = true) MultipleMarkRequest req) { - Preconditions.checkNotNull(req); - Preconditions.checkNotNull(req.getRequests()); - - User user = authenticationContext.getCurrentUser(); - for (MarkRequest r : req.getRequests()) { - Preconditions.checkNotNull(r.getId()); - feedEntryService.markEntry(user, Long.valueOf(r.getId()), r.isRead()); - } - - return Response.ok().build(); - } - - @Path("/star") - @POST - @Transactional - @Operation(summary = "Star a feed entry", description = "Mark a feed entry as read/unread") - public Response starEntry(@Valid @Parameter(description = "Star Request", required = true) StarRequest req) { - Preconditions.checkNotNull(req); - Preconditions.checkNotNull(req.getId()); - Preconditions.checkNotNull(req.getFeedId()); - - User user = authenticationContext.getCurrentUser(); - feedEntryService.starEntry(user, Long.valueOf(req.getId()), req.getFeedId(), req.isStarred()); - - return Response.ok().build(); - } - - @Path("/tags") - @GET - @Transactional - @Operation(summary = "Get list of tags for the user", description = "Get list of tags for the user") - public Response getTags() { - User user = authenticationContext.getCurrentUser(); - List tags = feedEntryTagDAO.findByUser(user); - return Response.ok(tags).build(); - } - - @Path("/tag") - @POST - @Transactional - @Operation(summary = "Set feed entry tags") - public Response tagEntry(@Valid @Parameter(description = "Tag Request", required = true) TagRequest req) { - Preconditions.checkNotNull(req); - Preconditions.checkNotNull(req.getEntryId()); - - User user = authenticationContext.getCurrentUser(); - feedEntryTagService.updateTags(user, req.getEntryId(), req.getTags()); - - return Response.ok().build(); - } - -} +package com.commafeed.frontend.resource; + +import java.util.List; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Singleton; +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import com.commafeed.backend.dao.FeedEntryTagDAO; +import com.commafeed.backend.model.User; +import com.commafeed.backend.service.FeedEntryService; +import com.commafeed.backend.service.FeedEntryTagService; +import com.commafeed.frontend.model.request.MarkRequest; +import com.commafeed.frontend.model.request.MultipleMarkRequest; +import com.commafeed.frontend.model.request.StarRequest; +import com.commafeed.frontend.model.request.TagRequest; +import com.commafeed.security.AuthenticationContext; +import com.commafeed.security.Roles; +import com.google.common.base.Preconditions; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Path("/rest/entry") +@RolesAllowed(Roles.USER) +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@RequiredArgsConstructor +@Singleton +@Tag(name = "Feed entries") +public class EntryREST { + + private final AuthenticationContext authenticationContext; + private final FeedEntryTagDAO feedEntryTagDAO; + private final FeedEntryService feedEntryService; + private final FeedEntryTagService feedEntryTagService; + + @Path("/mark") + @POST + @Transactional + @Operation(summary = "Mark a feed entry", description = "Mark a feed entry as read/unread") + public Response markEntry(@Valid @Parameter(description = "Mark Request", required = true) MarkRequest req) { + Preconditions.checkNotNull(req); + Preconditions.checkNotNull(req.getId()); + + User user = authenticationContext.getCurrentUser(); + feedEntryService.markEntry(user, Long.valueOf(req.getId()), req.isRead()); + return Response.ok().build(); + } + + @Path("/markMultiple") + @POST + @Transactional + @Operation(summary = "Mark multiple feed entries", description = "Mark feed entries as read/unread") + public Response markEntries(@Valid @Parameter(description = "Multiple Mark Request", required = true) MultipleMarkRequest req) { + Preconditions.checkNotNull(req); + Preconditions.checkNotNull(req.getRequests()); + + User user = authenticationContext.getCurrentUser(); + for (MarkRequest r : req.getRequests()) { + Preconditions.checkNotNull(r.getId()); + feedEntryService.markEntry(user, Long.valueOf(r.getId()), r.isRead()); + } + + return Response.ok().build(); + } + + @Path("/star") + @POST + @Transactional + @Operation(summary = "Star a feed entry", description = "Mark a feed entry as read/unread") + public Response starEntry(@Valid @Parameter(description = "Star Request", required = true) StarRequest req) { + Preconditions.checkNotNull(req); + Preconditions.checkNotNull(req.getId()); + Preconditions.checkNotNull(req.getFeedId()); + + User user = authenticationContext.getCurrentUser(); + feedEntryService.starEntry(user, Long.valueOf(req.getId()), req.getFeedId(), req.isStarred()); + + return Response.ok().build(); + } + + @Path("/tags") + @GET + @Transactional + @Operation(summary = "Get list of tags for the user", description = "Get list of tags for the user") + public Response getTags() { + User user = authenticationContext.getCurrentUser(); + List tags = feedEntryTagDAO.findByUser(user); + return Response.ok(tags).build(); + } + + @Path("/tag") + @POST + @Transactional + @Operation(summary = "Set feed entry tags") + public Response tagEntry(@Valid @Parameter(description = "Tag Request", required = true) TagRequest req) { + Preconditions.checkNotNull(req); + Preconditions.checkNotNull(req.getEntryId()); + + User user = authenticationContext.getCurrentUser(); + feedEntryTagService.updateTags(user, req.getEntryId(), req.getTags()); + + return Response.ok().build(); + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/resource/FeedREST.java b/commafeed-server/src/main/java/com/commafeed/frontend/resource/FeedREST.java index 2fc5271f..b44f756c 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/resource/FeedREST.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/resource/FeedREST.java @@ -1,524 +1,524 @@ -package com.commafeed.frontend.resource; - -import java.io.StringWriter; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.util.Collections; -import java.util.List; -import java.util.Objects; - -import jakarta.annotation.security.RolesAllowed; -import jakarta.inject.Singleton; -import jakarta.transaction.Transactional; -import jakarta.validation.Valid; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.DefaultValue; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.PathParam; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.QueryParam; -import jakarta.ws.rs.WebApplicationException; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.Response.Status; -import jakarta.ws.rs.core.UriInfo; - -import org.apache.commons.lang3.ObjectUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.SystemUtils; -import org.apache.hc.core5.http.HttpStatus; -import org.jboss.resteasy.reactive.Cache; -import org.jboss.resteasy.reactive.RestForm; - -import com.commafeed.CommaFeedApplication; -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.dao.FeedCategoryDAO; -import com.commafeed.backend.dao.FeedEntryStatusDAO; -import com.commafeed.backend.dao.FeedSubscriptionDAO; -import com.commafeed.backend.favicon.Favicon; -import com.commafeed.backend.feed.FeedEntryKeyword; -import com.commafeed.backend.feed.FeedFetcher; -import com.commafeed.backend.feed.FeedFetcher.FeedFetcherResult; -import com.commafeed.backend.feed.FeedRefreshEngine; -import com.commafeed.backend.feed.FeedUtils; -import com.commafeed.backend.model.Feed; -import com.commafeed.backend.model.FeedCategory; -import com.commafeed.backend.model.FeedEntry; -import com.commafeed.backend.model.FeedEntryContent; -import com.commafeed.backend.model.FeedEntryStatus; -import com.commafeed.backend.model.FeedSubscription; -import com.commafeed.backend.model.User; -import com.commafeed.backend.model.UserSettings.ReadingMode; -import com.commafeed.backend.model.UserSettings.ReadingOrder; -import com.commafeed.backend.opml.OPMLExporter; -import com.commafeed.backend.opml.OPMLImporter; -import com.commafeed.backend.service.FeedEntryFilteringService; -import com.commafeed.backend.service.FeedEntryFilteringService.FeedEntryFilterException; -import com.commafeed.backend.service.FeedEntryService; -import com.commafeed.backend.service.FeedService; -import com.commafeed.backend.service.FeedSubscriptionService; -import com.commafeed.backend.service.FeedSubscriptionService.ForceFeedRefreshTooSoonException; -import com.commafeed.frontend.model.Entries; -import com.commafeed.frontend.model.Entry; -import com.commafeed.frontend.model.FeedInfo; -import com.commafeed.frontend.model.Subscription; -import com.commafeed.frontend.model.UnreadCount; -import com.commafeed.frontend.model.request.FeedInfoRequest; -import com.commafeed.frontend.model.request.FeedModificationRequest; -import com.commafeed.frontend.model.request.IDRequest; -import com.commafeed.frontend.model.request.MarkRequest; -import com.commafeed.frontend.model.request.SubscribeRequest; -import com.commafeed.security.AuthenticationContext; -import com.commafeed.security.Roles; -import com.google.common.base.Preconditions; -import com.google.common.base.Throwables; -import com.rometools.opml.feed.opml.Opml; -import com.rometools.rome.feed.synd.SyndFeed; -import com.rometools.rome.feed.synd.SyndFeedImpl; -import com.rometools.rome.io.SyndFeedOutput; -import com.rometools.rome.io.WireFeedOutput; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Path("/rest/feed") -@RolesAllowed(Roles.USER) -@Slf4j -@Produces(MediaType.APPLICATION_JSON) -@Consumes(MediaType.APPLICATION_JSON) -@RequiredArgsConstructor -@Singleton -@Tag(name = "Feeds") -public class FeedREST { - - private static final FeedEntry TEST_ENTRY = initTestEntry(); - - private final AuthenticationContext authenticationContext; - private final FeedSubscriptionDAO feedSubscriptionDAO; - private final FeedCategoryDAO feedCategoryDAO; - private final FeedEntryStatusDAO feedEntryStatusDAO; - private final FeedFetcher feedFetcher; - private final FeedService feedService; - private final FeedEntryService feedEntryService; - private final FeedSubscriptionService feedSubscriptionService; - private final FeedEntryFilteringService feedEntryFilteringService; - private final FeedRefreshEngine feedRefreshEngine; - private final OPMLImporter opmlImporter; - private final OPMLExporter opmlExporter; - private final CommaFeedConfiguration config; - private final UriInfo uri; - - private static FeedEntry initTestEntry() { - FeedEntry entry = new FeedEntry(); - entry.setUrl("https://github.com/Athou/commafeed"); - - FeedEntryContent content = new FeedEntryContent(); - content.setAuthor("Athou"); - content.setTitle("Merge pull request #662 from Athou/dw8"); - content.setContent("Merge pull request #662 from Athou/dw8"); - entry.setContent(content); - return entry; - } - - @Path("/entries") - @GET - @Transactional - @Operation( - summary = "Get feed entries", - description = "Get a list of feed entries", - responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Entries.class))) }) - public Response getFeedEntries(@Parameter(description = "id of the feed", required = true) @QueryParam("id") String id, - @Parameter( - description = "all entries or only unread ones", - required = true) @DefaultValue("unread") @QueryParam("readType") ReadingMode readType, - @Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan, - @Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset, - @Parameter(description = "limit for paging, default 20, maximum 1000") @DefaultValue("20") @QueryParam("limit") int limit, - @Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order, @Parameter( - description = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords) { - - Preconditions.checkNotNull(id); - Preconditions.checkNotNull(readType); - - keywords = StringUtils.trimToNull(keywords); - Preconditions.checkArgument(keywords == null || StringUtils.length(keywords) >= 3); - List entryKeywords = FeedEntryKeyword.fromQueryString(keywords); - - limit = Math.min(limit, 1000); - limit = Math.max(0, limit); - - Entries entries = new Entries(); - entries.setOffset(offset); - entries.setLimit(limit); - - boolean unreadOnly = readType == ReadingMode.unread; - - Instant newerThanDate = newerThan == null ? null : Instant.ofEpochMilli(newerThan); - - User user = authenticationContext.getCurrentUser(); - FeedSubscription subscription = feedSubscriptionDAO.findById(user, Long.valueOf(id)); - if (subscription != null) { - entries.setName(subscription.getTitle()); - entries.setMessage(subscription.getFeed().getMessage()); - entries.setErrorCount(subscription.getFeed().getErrorCount()); - entries.setFeedLink(subscription.getFeed().getLink()); - - List list = feedEntryStatusDAO.findBySubscriptions(user, Collections.singletonList(subscription), unreadOnly, - entryKeywords, newerThanDate, offset, limit + 1, order, true, null, null, null); - - for (FeedEntryStatus status : list) { - entries.getEntries().add(Entry.build(status, config.imageProxyEnabled())); - } - - boolean hasMore = entries.getEntries().size() > limit; - if (hasMore) { - entries.setHasMore(true); - entries.getEntries().remove(entries.getEntries().size() - 1); - } - } else { - return Response.status(Status.NOT_FOUND).entity("feed not found").build(); - } - - entries.setTimestamp(System.currentTimeMillis()); - entries.setIgnoredReadStatus(keywords != null); - FeedUtils.removeUnwantedFromSearch(entries.getEntries(), entryKeywords); - return Response.ok(entries).build(); - } - - @Path("/entriesAsFeed") - @GET - @Transactional - @Operation(summary = "Get feed entries as a feed", description = "Get a feed of feed entries") - @Produces(MediaType.APPLICATION_XML) - public Response getFeedEntriesAsFeed(@Parameter(description = "id of the feed", required = true) @QueryParam("id") String id, - @Parameter( - description = "all entries or only unread ones", - required = true) @DefaultValue("all") @QueryParam("readType") ReadingMode readType, - @Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan, - @Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset, - @Parameter(description = "limit for paging, default 20, maximum 1000") @DefaultValue("20") @QueryParam("limit") int limit, - @Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order, @Parameter( - description = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords) { - - Response response = getFeedEntries(id, readType, newerThan, offset, limit, order, keywords); - if (response.getStatus() != Status.OK.getStatusCode()) { - return response; - } - Entries entries = (Entries) response.getEntity(); - - SyndFeed feed = new SyndFeedImpl(); - feed.setFeedType("rss_2.0"); - feed.setTitle("CommaFeed - " + entries.getName()); - feed.setDescription("CommaFeed - " + entries.getName()); - feed.setLink(uri.getBaseUri().toString()); - feed.setEntries(entries.getEntries().stream().map(Entry::asRss).toList()); - - SyndFeedOutput output = new SyndFeedOutput(); - StringWriter writer = new StringWriter(); - try { - output.output(feed, writer); - } catch (Exception e) { - writer.write("Could not get feed information"); - log.error(e.getMessage(), e); - } - return Response.ok(writer.toString()).build(); - } - - private FeedInfo fetchFeedInternal(String url) { - FeedInfo info; - url = StringUtils.trimToEmpty(url); - url = prependHttp(url); - try { - FeedFetcherResult feedFetcherResult = feedFetcher.fetch(url, true, null, null, null, null); - info = new FeedInfo(); - info.setUrl(feedFetcherResult.urlAfterRedirect()); - info.setTitle(feedFetcherResult.feed().title()); - - } catch (Exception e) { - log.debug(e.getMessage(), e); - throw new WebApplicationException(e.getMessage(), Status.INTERNAL_SERVER_ERROR); - } - return info; - } - - @POST - @Path("/fetch") - @Transactional - @Operation( - summary = "Fetch a feed", - description = "Fetch a feed by its url", - responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = FeedInfo.class))) }) - public Response fetchFeed(@Valid @Parameter(description = "feed url", required = true) FeedInfoRequest req) { - Preconditions.checkNotNull(req); - Preconditions.checkNotNull(req.getUrl()); - - FeedInfo info; - try { - info = fetchFeedInternal(req.getUrl()); - } catch (Exception e) { - Throwable cause = Throwables.getRootCause(e); - return Response.status(Status.INTERNAL_SERVER_ERROR).entity(cause.getMessage()).type(MediaType.TEXT_PLAIN).build(); - } - return Response.ok(info).build(); - } - - @Path("/refreshAll") - @GET - @Transactional - @Operation(summary = "Queue all feeds of the user for refresh", description = "Manually add all feeds of the user to the refresh queue") - public Response queueAllForRefresh() { - User user = authenticationContext.getCurrentUser(); - try { - feedSubscriptionService.refreshAll(user); - return Response.ok().build(); - } catch (ForceFeedRefreshTooSoonException e) { - return Response.status(HttpStatus.SC_TOO_MANY_REQUESTS).build(); - } - - } - - @Path("/refresh") - @POST - @Transactional - @Operation(summary = "Queue a feed for refresh", description = "Manually add a feed to the refresh queue") - public Response queueForRefresh(@Parameter(description = "Feed id", required = true) IDRequest req) { - Preconditions.checkNotNull(req); - Preconditions.checkNotNull(req.getId()); - - User user = authenticationContext.getCurrentUser(); - FeedSubscription sub = feedSubscriptionDAO.findById(user, req.getId()); - if (sub != null) { - Feed feed = sub.getFeed(); - feedRefreshEngine.refreshImmediately(feed); - return Response.ok().build(); - } - return Response.ok(Status.NOT_FOUND).build(); - } - - @Path("/mark") - @POST - @Transactional - @Operation(summary = "Mark feed entries", description = "Mark feed entries as read (unread is not supported)") - public Response markFeedEntries(@Valid @Parameter(description = "Mark request", required = true) MarkRequest req) { - Preconditions.checkNotNull(req); - Preconditions.checkNotNull(req.getId()); - - Instant olderThan = req.getOlderThan() == null ? null : Instant.ofEpochMilli(req.getOlderThan()); - Instant insertedBefore = req.getInsertedBefore() == null ? null : Instant.ofEpochMilli(req.getInsertedBefore()); - String keywords = req.getKeywords(); - List entryKeywords = FeedEntryKeyword.fromQueryString(keywords); - - User user = authenticationContext.getCurrentUser(); - FeedSubscription subscription = feedSubscriptionDAO.findById(user, Long.valueOf(req.getId())); - if (subscription != null) { - feedEntryService.markSubscriptionEntries(user, Collections.singletonList(subscription), olderThan, insertedBefore, - entryKeywords); - } - return Response.ok().build(); - } - - @GET - @Path("/get/{id}") - @Transactional - @Operation( - summary = "get feed", - responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Subscription.class))) }) - public Response getFeed(@Parameter(description = "user id", required = true) @PathParam("id") Long id) { - Preconditions.checkNotNull(id); - - User user = authenticationContext.getCurrentUser(); - FeedSubscription sub = feedSubscriptionDAO.findById(user, id); - if (sub == null) { - return Response.status(Status.NOT_FOUND).build(); - } - UnreadCount unreadCount = feedSubscriptionService.getUnreadCount(user).get(id); - return Response.ok(Subscription.build(sub, unreadCount)).build(); - } - - @GET - @Path("/favicon/{id}") - @Cache(maxAge = 2592000) - @Operation(summary = "Fetch a feed's icon", description = "Fetch a feed's icon") - public Response getFeedFavicon(@Parameter(description = "subscription id", required = true) @PathParam("id") Long id) { - Preconditions.checkNotNull(id); - - User user = authenticationContext.getCurrentUser(); - FeedSubscription subscription = feedSubscriptionDAO.findById(user, id); - if (subscription == null) { - return Response.status(Status.NOT_FOUND).build(); - } - - Feed feed = subscription.getFeed(); - Favicon icon = feedService.fetchFavicon(feed); - return Response.ok(icon.getIcon(), icon.getMediaType()).build(); - } - - @POST - @Path("/subscribe") - @Transactional - @Operation( - summary = "Subscribe to a feed", - description = "Subscribe to a feed", - responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Long.class))) }) - public Response subscribe(@Valid @Parameter(description = "subscription request", required = true) SubscribeRequest req) { - Preconditions.checkNotNull(req); - Preconditions.checkNotNull(req.getTitle()); - Preconditions.checkNotNull(req.getUrl()); - - try { - FeedCategory category = null; - if (req.getCategoryId() != null && !CategoryREST.ALL.equals(req.getCategoryId())) { - category = feedCategoryDAO.findById(Long.valueOf(req.getCategoryId())); - } - - FeedInfo info = fetchFeedInternal(prependHttp(req.getUrl())); - User user = authenticationContext.getCurrentUser(); - long subscriptionId = feedSubscriptionService.subscribe(user, info.getUrl(), req.getTitle(), category); - return Response.ok(subscriptionId).build(); - } catch (Exception e) { - log.error("Failed to subscribe to URL {}: {}", req.getUrl(), e.getMessage(), e); - return Response.status(Status.SERVICE_UNAVAILABLE) - .entity("Failed to subscribe to URL " + req.getUrl() + ": " + e.getMessage()) - .build(); - } - } - - @GET - @Path("/subscribe") - @Transactional - @Operation(summary = "Subscribe to a feed", description = "Subscribe to a feed") - public Response subscribeFromUrl(@Parameter(description = "feed url", required = true) @QueryParam("url") String url) { - try { - Preconditions.checkNotNull(url); - FeedInfo info = fetchFeedInternal(prependHttp(url)); - User user = authenticationContext.getCurrentUser(); - feedSubscriptionService.subscribe(user, info.getUrl(), info.getTitle()); - } catch (Exception e) { - log.info("Could not subscribe to url {} : {}", url, e.getMessage()); - } - return Response.temporaryRedirect(uri.getBaseUri()).build(); - } - - private String prependHttp(String url) { - if (!url.startsWith("http")) { - url = "http://" + url; - } - return url; - } - - @POST - @Path("/unsubscribe") - @Transactional - @Operation(summary = "Unsubscribe from a feed", description = "Unsubscribe from a feed") - public Response unsubscribe(@Parameter(required = true) IDRequest req) { - Preconditions.checkNotNull(req); - Preconditions.checkNotNull(req.getId()); - - User user = authenticationContext.getCurrentUser(); - boolean deleted = feedSubscriptionService.unsubscribe(user, req.getId()); - if (deleted) { - return Response.ok().build(); - } else { - return Response.status(Status.NOT_FOUND).build(); - } - } - - @POST - @Path("/modify") - @Transactional - @Operation(summary = "Modify a subscription", description = "Modify a feed subscription") - public Response modifyFeed(@Valid @Parameter(description = "subscription id", required = true) FeedModificationRequest req) { - Preconditions.checkNotNull(req); - Preconditions.checkNotNull(req.getId()); - - try { - feedEntryFilteringService.filterMatchesEntry(req.getFilter(), TEST_ENTRY); - } catch (FeedEntryFilterException e) { - return Response.status(Status.BAD_REQUEST).entity(e.getCause().getMessage()).type(MediaType.TEXT_PLAIN).build(); - } - - User user = authenticationContext.getCurrentUser(); - FeedSubscription subscription = feedSubscriptionDAO.findById(user, req.getId()); - subscription.setFilter(StringUtils.lowerCase(req.getFilter())); - - if (StringUtils.isNotBlank(req.getName())) { - subscription.setTitle(req.getName()); - } - - FeedCategory parent = null; - if (req.getCategoryId() != null && !CategoryREST.ALL.equals(req.getCategoryId())) { - parent = feedCategoryDAO.findById(user, Long.valueOf(req.getCategoryId())); - } - subscription.setCategory(parent); - - if (req.getPosition() != null) { - List subs = feedSubscriptionDAO.findByCategory(user, parent); - subs.sort((o1, o2) -> ObjectUtils.compare(o1.getPosition(), o2.getPosition())); - - int existingIndex = -1; - for (int i = 0; i < subs.size(); i++) { - if (Objects.equals(subs.get(i).getId(), subscription.getId())) { - existingIndex = i; - } - } - if (existingIndex != -1) { - subs.remove(existingIndex); - } - - subs.add(Math.min(req.getPosition(), subs.size()), subscription); - for (int i = 0; i < subs.size(); i++) { - subs.get(i).setPosition(i); - } - feedSubscriptionDAO.saveOrUpdate(subs); - } else { - feedSubscriptionDAO.saveOrUpdate(subscription); - } - return Response.ok().build(); - } - - @POST - @Path("/import") - @Transactional - @Consumes(MediaType.MULTIPART_FORM_DATA) - @Operation(summary = "OPML import", description = "Import an OPML file, posted as a FORM with the 'file' name") - public Response importOpml(@Parameter(description = "ompl file", required = true) @RestForm("file") String opml) { - User user = authenticationContext.getCurrentUser(); - if (CommaFeedApplication.USERNAME_DEMO.equals(user.getName())) { - return Response.status(Status.FORBIDDEN).entity("Import is disabled for the demo account").build(); - } - try { - // opml will be encoded in the default JVM encoding, bu we want UTF-8 - opmlImporter.importOpml(user, new String(opml.getBytes(SystemUtils.FILE_ENCODING), StandardCharsets.UTF_8)); - } catch (Exception e) { - return Response.status(Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).build(); - } - return Response.ok().build(); - } - - @GET - @Path("/export") - @Transactional - @Produces(MediaType.APPLICATION_XML) - @Operation(summary = "OPML export", description = "Export an OPML file of the user's subscriptions") - public Response exportOpml() { - User user = authenticationContext.getCurrentUser(); - Opml opml = opmlExporter.export(user); - WireFeedOutput output = new WireFeedOutput(); - String opmlString; - try { - opmlString = output.outputString(opml); - } catch (Exception e) { - return Response.status(Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).build(); - } - return Response.ok(opmlString).build(); - } - -} +package com.commafeed.frontend.resource; + +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Singleton; +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.core.UriInfo; + +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.SystemUtils; +import org.apache.hc.core5.http.HttpStatus; +import org.jboss.resteasy.reactive.Cache; +import org.jboss.resteasy.reactive.RestForm; + +import com.commafeed.CommaFeedApplication; +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.backend.dao.FeedCategoryDAO; +import com.commafeed.backend.dao.FeedEntryStatusDAO; +import com.commafeed.backend.dao.FeedSubscriptionDAO; +import com.commafeed.backend.favicon.Favicon; +import com.commafeed.backend.feed.FeedEntryKeyword; +import com.commafeed.backend.feed.FeedFetcher; +import com.commafeed.backend.feed.FeedFetcher.FeedFetcherResult; +import com.commafeed.backend.feed.FeedRefreshEngine; +import com.commafeed.backend.feed.FeedUtils; +import com.commafeed.backend.model.Feed; +import com.commafeed.backend.model.FeedCategory; +import com.commafeed.backend.model.FeedEntry; +import com.commafeed.backend.model.FeedEntryContent; +import com.commafeed.backend.model.FeedEntryStatus; +import com.commafeed.backend.model.FeedSubscription; +import com.commafeed.backend.model.User; +import com.commafeed.backend.model.UserSettings.ReadingMode; +import com.commafeed.backend.model.UserSettings.ReadingOrder; +import com.commafeed.backend.opml.OPMLExporter; +import com.commafeed.backend.opml.OPMLImporter; +import com.commafeed.backend.service.FeedEntryFilteringService; +import com.commafeed.backend.service.FeedEntryFilteringService.FeedEntryFilterException; +import com.commafeed.backend.service.FeedEntryService; +import com.commafeed.backend.service.FeedService; +import com.commafeed.backend.service.FeedSubscriptionService; +import com.commafeed.backend.service.FeedSubscriptionService.ForceFeedRefreshTooSoonException; +import com.commafeed.frontend.model.Entries; +import com.commafeed.frontend.model.Entry; +import com.commafeed.frontend.model.FeedInfo; +import com.commafeed.frontend.model.Subscription; +import com.commafeed.frontend.model.UnreadCount; +import com.commafeed.frontend.model.request.FeedInfoRequest; +import com.commafeed.frontend.model.request.FeedModificationRequest; +import com.commafeed.frontend.model.request.IDRequest; +import com.commafeed.frontend.model.request.MarkRequest; +import com.commafeed.frontend.model.request.SubscribeRequest; +import com.commafeed.security.AuthenticationContext; +import com.commafeed.security.Roles; +import com.google.common.base.Preconditions; +import com.google.common.base.Throwables; +import com.rometools.opml.feed.opml.Opml; +import com.rometools.rome.feed.synd.SyndFeed; +import com.rometools.rome.feed.synd.SyndFeedImpl; +import com.rometools.rome.io.SyndFeedOutput; +import com.rometools.rome.io.WireFeedOutput; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Path("/rest/feed") +@RolesAllowed(Roles.USER) +@Slf4j +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@RequiredArgsConstructor +@Singleton +@Tag(name = "Feeds") +public class FeedREST { + + private static final FeedEntry TEST_ENTRY = initTestEntry(); + + private final AuthenticationContext authenticationContext; + private final FeedSubscriptionDAO feedSubscriptionDAO; + private final FeedCategoryDAO feedCategoryDAO; + private final FeedEntryStatusDAO feedEntryStatusDAO; + private final FeedFetcher feedFetcher; + private final FeedService feedService; + private final FeedEntryService feedEntryService; + private final FeedSubscriptionService feedSubscriptionService; + private final FeedEntryFilteringService feedEntryFilteringService; + private final FeedRefreshEngine feedRefreshEngine; + private final OPMLImporter opmlImporter; + private final OPMLExporter opmlExporter; + private final CommaFeedConfiguration config; + private final UriInfo uri; + + private static FeedEntry initTestEntry() { + FeedEntry entry = new FeedEntry(); + entry.setUrl("https://github.com/Athou/commafeed"); + + FeedEntryContent content = new FeedEntryContent(); + content.setAuthor("Athou"); + content.setTitle("Merge pull request #662 from Athou/dw8"); + content.setContent("Merge pull request #662 from Athou/dw8"); + entry.setContent(content); + return entry; + } + + @Path("/entries") + @GET + @Transactional + @Operation( + summary = "Get feed entries", + description = "Get a list of feed entries", + responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Entries.class))) }) + public Response getFeedEntries(@Parameter(description = "id of the feed", required = true) @QueryParam("id") String id, + @Parameter( + description = "all entries or only unread ones", + required = true) @DefaultValue("unread") @QueryParam("readType") ReadingMode readType, + @Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan, + @Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset, + @Parameter(description = "limit for paging, default 20, maximum 1000") @DefaultValue("20") @QueryParam("limit") int limit, + @Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order, @Parameter( + description = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords) { + + Preconditions.checkNotNull(id); + Preconditions.checkNotNull(readType); + + keywords = StringUtils.trimToNull(keywords); + Preconditions.checkArgument(keywords == null || StringUtils.length(keywords) >= 3); + List entryKeywords = FeedEntryKeyword.fromQueryString(keywords); + + limit = Math.min(limit, 1000); + limit = Math.max(0, limit); + + Entries entries = new Entries(); + entries.setOffset(offset); + entries.setLimit(limit); + + boolean unreadOnly = readType == ReadingMode.unread; + + Instant newerThanDate = newerThan == null ? null : Instant.ofEpochMilli(newerThan); + + User user = authenticationContext.getCurrentUser(); + FeedSubscription subscription = feedSubscriptionDAO.findById(user, Long.valueOf(id)); + if (subscription != null) { + entries.setName(subscription.getTitle()); + entries.setMessage(subscription.getFeed().getMessage()); + entries.setErrorCount(subscription.getFeed().getErrorCount()); + entries.setFeedLink(subscription.getFeed().getLink()); + + List list = feedEntryStatusDAO.findBySubscriptions(user, Collections.singletonList(subscription), unreadOnly, + entryKeywords, newerThanDate, offset, limit + 1, order, true, null, null, null); + + for (FeedEntryStatus status : list) { + entries.getEntries().add(Entry.build(status, config.imageProxyEnabled())); + } + + boolean hasMore = entries.getEntries().size() > limit; + if (hasMore) { + entries.setHasMore(true); + entries.getEntries().remove(entries.getEntries().size() - 1); + } + } else { + return Response.status(Status.NOT_FOUND).entity("feed not found").build(); + } + + entries.setTimestamp(System.currentTimeMillis()); + entries.setIgnoredReadStatus(keywords != null); + FeedUtils.removeUnwantedFromSearch(entries.getEntries(), entryKeywords); + return Response.ok(entries).build(); + } + + @Path("/entriesAsFeed") + @GET + @Transactional + @Operation(summary = "Get feed entries as a feed", description = "Get a feed of feed entries") + @Produces(MediaType.APPLICATION_XML) + public Response getFeedEntriesAsFeed(@Parameter(description = "id of the feed", required = true) @QueryParam("id") String id, + @Parameter( + description = "all entries or only unread ones", + required = true) @DefaultValue("all") @QueryParam("readType") ReadingMode readType, + @Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan, + @Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset, + @Parameter(description = "limit for paging, default 20, maximum 1000") @DefaultValue("20") @QueryParam("limit") int limit, + @Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order, @Parameter( + description = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords) { + + Response response = getFeedEntries(id, readType, newerThan, offset, limit, order, keywords); + if (response.getStatus() != Status.OK.getStatusCode()) { + return response; + } + Entries entries = (Entries) response.getEntity(); + + SyndFeed feed = new SyndFeedImpl(); + feed.setFeedType("rss_2.0"); + feed.setTitle("CommaFeed - " + entries.getName()); + feed.setDescription("CommaFeed - " + entries.getName()); + feed.setLink(uri.getBaseUri().toString()); + feed.setEntries(entries.getEntries().stream().map(Entry::asRss).toList()); + + SyndFeedOutput output = new SyndFeedOutput(); + StringWriter writer = new StringWriter(); + try { + output.output(feed, writer); + } catch (Exception e) { + writer.write("Could not get feed information"); + log.error(e.getMessage(), e); + } + return Response.ok(writer.toString()).build(); + } + + private FeedInfo fetchFeedInternal(String url) { + FeedInfo info; + url = StringUtils.trimToEmpty(url); + url = prependHttp(url); + try { + FeedFetcherResult feedFetcherResult = feedFetcher.fetch(url, true, null, null, null, null); + info = new FeedInfo(); + info.setUrl(feedFetcherResult.urlAfterRedirect()); + info.setTitle(feedFetcherResult.feed().title()); + + } catch (Exception e) { + log.debug(e.getMessage(), e); + throw new WebApplicationException(e.getMessage(), Status.INTERNAL_SERVER_ERROR); + } + return info; + } + + @POST + @Path("/fetch") + @Transactional + @Operation( + summary = "Fetch a feed", + description = "Fetch a feed by its url", + responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = FeedInfo.class))) }) + public Response fetchFeed(@Valid @Parameter(description = "feed url", required = true) FeedInfoRequest req) { + Preconditions.checkNotNull(req); + Preconditions.checkNotNull(req.getUrl()); + + FeedInfo info; + try { + info = fetchFeedInternal(req.getUrl()); + } catch (Exception e) { + Throwable cause = Throwables.getRootCause(e); + return Response.status(Status.INTERNAL_SERVER_ERROR).entity(cause.getMessage()).type(MediaType.TEXT_PLAIN).build(); + } + return Response.ok(info).build(); + } + + @Path("/refreshAll") + @GET + @Transactional + @Operation(summary = "Queue all feeds of the user for refresh", description = "Manually add all feeds of the user to the refresh queue") + public Response queueAllForRefresh() { + User user = authenticationContext.getCurrentUser(); + try { + feedSubscriptionService.refreshAll(user); + return Response.ok().build(); + } catch (ForceFeedRefreshTooSoonException e) { + return Response.status(HttpStatus.SC_TOO_MANY_REQUESTS).build(); + } + + } + + @Path("/refresh") + @POST + @Transactional + @Operation(summary = "Queue a feed for refresh", description = "Manually add a feed to the refresh queue") + public Response queueForRefresh(@Parameter(description = "Feed id", required = true) IDRequest req) { + Preconditions.checkNotNull(req); + Preconditions.checkNotNull(req.getId()); + + User user = authenticationContext.getCurrentUser(); + FeedSubscription sub = feedSubscriptionDAO.findById(user, req.getId()); + if (sub != null) { + Feed feed = sub.getFeed(); + feedRefreshEngine.refreshImmediately(feed); + return Response.ok().build(); + } + return Response.ok(Status.NOT_FOUND).build(); + } + + @Path("/mark") + @POST + @Transactional + @Operation(summary = "Mark feed entries", description = "Mark feed entries as read (unread is not supported)") + public Response markFeedEntries(@Valid @Parameter(description = "Mark request", required = true) MarkRequest req) { + Preconditions.checkNotNull(req); + Preconditions.checkNotNull(req.getId()); + + Instant olderThan = req.getOlderThan() == null ? null : Instant.ofEpochMilli(req.getOlderThan()); + Instant insertedBefore = req.getInsertedBefore() == null ? null : Instant.ofEpochMilli(req.getInsertedBefore()); + String keywords = req.getKeywords(); + List entryKeywords = FeedEntryKeyword.fromQueryString(keywords); + + User user = authenticationContext.getCurrentUser(); + FeedSubscription subscription = feedSubscriptionDAO.findById(user, Long.valueOf(req.getId())); + if (subscription != null) { + feedEntryService.markSubscriptionEntries(user, Collections.singletonList(subscription), olderThan, insertedBefore, + entryKeywords); + } + return Response.ok().build(); + } + + @GET + @Path("/get/{id}") + @Transactional + @Operation( + summary = "get feed", + responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Subscription.class))) }) + public Response getFeed(@Parameter(description = "user id", required = true) @PathParam("id") Long id) { + Preconditions.checkNotNull(id); + + User user = authenticationContext.getCurrentUser(); + FeedSubscription sub = feedSubscriptionDAO.findById(user, id); + if (sub == null) { + return Response.status(Status.NOT_FOUND).build(); + } + UnreadCount unreadCount = feedSubscriptionService.getUnreadCount(user).get(id); + return Response.ok(Subscription.build(sub, unreadCount)).build(); + } + + @GET + @Path("/favicon/{id}") + @Cache(maxAge = 2592000) + @Operation(summary = "Fetch a feed's icon", description = "Fetch a feed's icon") + public Response getFeedFavicon(@Parameter(description = "subscription id", required = true) @PathParam("id") Long id) { + Preconditions.checkNotNull(id); + + User user = authenticationContext.getCurrentUser(); + FeedSubscription subscription = feedSubscriptionDAO.findById(user, id); + if (subscription == null) { + return Response.status(Status.NOT_FOUND).build(); + } + + Feed feed = subscription.getFeed(); + Favicon icon = feedService.fetchFavicon(feed); + return Response.ok(icon.getIcon(), icon.getMediaType()).build(); + } + + @POST + @Path("/subscribe") + @Transactional + @Operation( + summary = "Subscribe to a feed", + description = "Subscribe to a feed", + responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Long.class))) }) + public Response subscribe(@Valid @Parameter(description = "subscription request", required = true) SubscribeRequest req) { + Preconditions.checkNotNull(req); + Preconditions.checkNotNull(req.getTitle()); + Preconditions.checkNotNull(req.getUrl()); + + try { + FeedCategory category = null; + if (req.getCategoryId() != null && !CategoryREST.ALL.equals(req.getCategoryId())) { + category = feedCategoryDAO.findById(Long.valueOf(req.getCategoryId())); + } + + FeedInfo info = fetchFeedInternal(prependHttp(req.getUrl())); + User user = authenticationContext.getCurrentUser(); + long subscriptionId = feedSubscriptionService.subscribe(user, info.getUrl(), req.getTitle(), category); + return Response.ok(subscriptionId).build(); + } catch (Exception e) { + log.error("Failed to subscribe to URL {}: {}", req.getUrl(), e.getMessage(), e); + return Response.status(Status.SERVICE_UNAVAILABLE) + .entity("Failed to subscribe to URL " + req.getUrl() + ": " + e.getMessage()) + .build(); + } + } + + @GET + @Path("/subscribe") + @Transactional + @Operation(summary = "Subscribe to a feed", description = "Subscribe to a feed") + public Response subscribeFromUrl(@Parameter(description = "feed url", required = true) @QueryParam("url") String url) { + try { + Preconditions.checkNotNull(url); + FeedInfo info = fetchFeedInternal(prependHttp(url)); + User user = authenticationContext.getCurrentUser(); + feedSubscriptionService.subscribe(user, info.getUrl(), info.getTitle()); + } catch (Exception e) { + log.info("Could not subscribe to url {} : {}", url, e.getMessage()); + } + return Response.temporaryRedirect(uri.getBaseUri()).build(); + } + + private String prependHttp(String url) { + if (!url.startsWith("http")) { + url = "http://" + url; + } + return url; + } + + @POST + @Path("/unsubscribe") + @Transactional + @Operation(summary = "Unsubscribe from a feed", description = "Unsubscribe from a feed") + public Response unsubscribe(@Parameter(required = true) IDRequest req) { + Preconditions.checkNotNull(req); + Preconditions.checkNotNull(req.getId()); + + User user = authenticationContext.getCurrentUser(); + boolean deleted = feedSubscriptionService.unsubscribe(user, req.getId()); + if (deleted) { + return Response.ok().build(); + } else { + return Response.status(Status.NOT_FOUND).build(); + } + } + + @POST + @Path("/modify") + @Transactional + @Operation(summary = "Modify a subscription", description = "Modify a feed subscription") + public Response modifyFeed(@Valid @Parameter(description = "subscription id", required = true) FeedModificationRequest req) { + Preconditions.checkNotNull(req); + Preconditions.checkNotNull(req.getId()); + + try { + feedEntryFilteringService.filterMatchesEntry(req.getFilter(), TEST_ENTRY); + } catch (FeedEntryFilterException e) { + return Response.status(Status.BAD_REQUEST).entity(e.getCause().getMessage()).type(MediaType.TEXT_PLAIN).build(); + } + + User user = authenticationContext.getCurrentUser(); + FeedSubscription subscription = feedSubscriptionDAO.findById(user, req.getId()); + subscription.setFilter(StringUtils.lowerCase(req.getFilter())); + + if (StringUtils.isNotBlank(req.getName())) { + subscription.setTitle(req.getName()); + } + + FeedCategory parent = null; + if (req.getCategoryId() != null && !CategoryREST.ALL.equals(req.getCategoryId())) { + parent = feedCategoryDAO.findById(user, Long.valueOf(req.getCategoryId())); + } + subscription.setCategory(parent); + + if (req.getPosition() != null) { + List subs = feedSubscriptionDAO.findByCategory(user, parent); + subs.sort((o1, o2) -> ObjectUtils.compare(o1.getPosition(), o2.getPosition())); + + int existingIndex = -1; + for (int i = 0; i < subs.size(); i++) { + if (Objects.equals(subs.get(i).getId(), subscription.getId())) { + existingIndex = i; + } + } + if (existingIndex != -1) { + subs.remove(existingIndex); + } + + subs.add(Math.min(req.getPosition(), subs.size()), subscription); + for (int i = 0; i < subs.size(); i++) { + subs.get(i).setPosition(i); + } + feedSubscriptionDAO.saveOrUpdate(subs); + } else { + feedSubscriptionDAO.saveOrUpdate(subscription); + } + return Response.ok().build(); + } + + @POST + @Path("/import") + @Transactional + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation(summary = "OPML import", description = "Import an OPML file, posted as a FORM with the 'file' name") + public Response importOpml(@Parameter(description = "ompl file", required = true) @RestForm("file") String opml) { + User user = authenticationContext.getCurrentUser(); + if (CommaFeedApplication.USERNAME_DEMO.equals(user.getName())) { + return Response.status(Status.FORBIDDEN).entity("Import is disabled for the demo account").build(); + } + try { + // opml will be encoded in the default JVM encoding, bu we want UTF-8 + opmlImporter.importOpml(user, new String(opml.getBytes(SystemUtils.FILE_ENCODING), StandardCharsets.UTF_8)); + } catch (Exception e) { + return Response.status(Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).build(); + } + return Response.ok().build(); + } + + @GET + @Path("/export") + @Transactional + @Produces(MediaType.APPLICATION_XML) + @Operation(summary = "OPML export", description = "Export an OPML file of the user's subscriptions") + public Response exportOpml() { + User user = authenticationContext.getCurrentUser(); + Opml opml = opmlExporter.export(user); + WireFeedOutput output = new WireFeedOutput(); + String opmlString; + try { + opmlString = output.outputString(opml); + } catch (Exception e) { + return Response.status(Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).build(); + } + return Response.ok(opmlString).build(); + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/resource/OpenAPI.java b/commafeed-server/src/main/java/com/commafeed/frontend/resource/OpenAPI.java index 729a75bd..8d396faa 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/resource/OpenAPI.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/resource/OpenAPI.java @@ -1,16 +1,16 @@ -package com.commafeed.frontend.resource; - -import io.swagger.v3.oas.annotations.OpenAPIDefinition; -import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; -import io.swagger.v3.oas.annotations.info.Info; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.security.SecurityScheme; -import io.swagger.v3.oas.annotations.servers.Server; - -@OpenAPIDefinition( - info = @Info(title = "CommaFeed API"), - servers = { @Server(description = "CommaFeed API", url = "/") }, - security = { @SecurityRequirement(name = "basicAuth") }) -@SecurityScheme(name = "basicAuth", type = SecuritySchemeType.HTTP, scheme = "basic") -public class OpenAPI { -} +package com.commafeed.frontend.resource; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.annotations.servers.Server; + +@OpenAPIDefinition( + info = @Info(title = "CommaFeed API"), + servers = { @Server(description = "CommaFeed API", url = "/") }, + security = { @SecurityRequirement(name = "basicAuth") }) +@SecurityScheme(name = "basicAuth", type = SecuritySchemeType.HTTP, scheme = "basic") +public class OpenAPI { +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/resource/ServerREST.java b/commafeed-server/src/main/java/com/commafeed/frontend/resource/ServerREST.java index 04190c2d..e59049cf 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/resource/ServerREST.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/resource/ServerREST.java @@ -1,88 +1,88 @@ -package com.commafeed.frontend.resource; - -import jakarta.annotation.security.PermitAll; -import jakarta.annotation.security.RolesAllowed; -import jakarta.inject.Singleton; -import jakarta.transaction.Transactional; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.QueryParam; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.Response.Status; - -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.CommaFeedVersion; -import com.commafeed.backend.HttpGetter; -import com.commafeed.backend.HttpGetter.HttpResult; -import com.commafeed.backend.feed.FeedUtils; -import com.commafeed.frontend.model.ServerInfo; -import com.commafeed.security.Roles; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; - -@Path("/rest/server") - -@Produces(MediaType.APPLICATION_JSON) -@Consumes(MediaType.APPLICATION_JSON) -@RequiredArgsConstructor -@Singleton -@Tag(name = "Server") -public class ServerREST { - - private final HttpGetter httpGetter; - private final CommaFeedConfiguration config; - private final CommaFeedVersion version; - - @Path("/get") - @GET - @PermitAll - @Transactional - @Operation( - summary = "Get server infos", - description = "Get server infos", - responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = ServerInfo.class))) }) - public Response getServerInfos() { - ServerInfo infos = new ServerInfo(); - infos.setAnnouncement(config.announcement().orElse(null)); - infos.setVersion(version.getVersion()); - infos.setGitCommit(version.getGitCommit()); - infos.setAllowRegistrations(config.users().allowRegistrations()); - infos.setGoogleAnalyticsCode(config.googleAnalyticsTrackingCode().orElse(null)); - infos.setSmtpEnabled(config.passwordRecoveryEnabled()); - infos.setDemoAccountEnabled(config.users().createDemoAccount()); - infos.setWebsocketEnabled(config.websocket().enabled()); - infos.setWebsocketPingInterval(config.websocket().pingInterval().toMillis()); - infos.setTreeReloadInterval(config.websocket().treeReloadInterval().toMillis()); - infos.setForceRefreshCooldownDuration(config.feedRefresh().forceRefreshCooldownDuration().toMillis()); - return Response.ok(infos).build(); - } - - @Path("/proxy") - @GET - @RolesAllowed(Roles.USER) - @Transactional - @Operation(summary = "proxy image") - @Produces("image/png") - public Response getProxiedImage(@Parameter(description = "image url", required = true) @QueryParam("u") String url) { - if (!config.imageProxyEnabled()) { - return Response.status(Status.FORBIDDEN).build(); - } - - url = FeedUtils.imageProxyDecoder(url); - try { - HttpResult result = httpGetter.get(url); - return Response.ok(result.getContent()).build(); - } catch (Exception e) { - return Response.status(Status.SERVICE_UNAVAILABLE).entity(e.getMessage()).build(); - } - } -} +package com.commafeed.frontend.resource; + +import jakarta.annotation.security.PermitAll; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Singleton; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; + +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.CommaFeedVersion; +import com.commafeed.backend.HttpGetter; +import com.commafeed.backend.HttpGetter.HttpResult; +import com.commafeed.backend.feed.FeedUtils; +import com.commafeed.frontend.model.ServerInfo; +import com.commafeed.security.Roles; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Path("/rest/server") + +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@RequiredArgsConstructor +@Singleton +@Tag(name = "Server") +public class ServerREST { + + private final HttpGetter httpGetter; + private final CommaFeedConfiguration config; + private final CommaFeedVersion version; + + @Path("/get") + @GET + @PermitAll + @Transactional + @Operation( + summary = "Get server infos", + description = "Get server infos", + responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = ServerInfo.class))) }) + public Response getServerInfos() { + ServerInfo infos = new ServerInfo(); + infos.setAnnouncement(config.announcement().orElse(null)); + infos.setVersion(version.getVersion()); + infos.setGitCommit(version.getGitCommit()); + infos.setAllowRegistrations(config.users().allowRegistrations()); + infos.setGoogleAnalyticsCode(config.googleAnalyticsTrackingCode().orElse(null)); + infos.setSmtpEnabled(config.passwordRecoveryEnabled()); + infos.setDemoAccountEnabled(config.users().createDemoAccount()); + infos.setWebsocketEnabled(config.websocket().enabled()); + infos.setWebsocketPingInterval(config.websocket().pingInterval().toMillis()); + infos.setTreeReloadInterval(config.websocket().treeReloadInterval().toMillis()); + infos.setForceRefreshCooldownDuration(config.feedRefresh().forceRefreshCooldownDuration().toMillis()); + return Response.ok(infos).build(); + } + + @Path("/proxy") + @GET + @RolesAllowed(Roles.USER) + @Transactional + @Operation(summary = "proxy image") + @Produces("image/png") + public Response getProxiedImage(@Parameter(description = "image url", required = true) @QueryParam("u") String url) { + if (!config.imageProxyEnabled()) { + return Response.status(Status.FORBIDDEN).build(); + } + + url = FeedUtils.imageProxyDecoder(url); + try { + HttpResult result = httpGetter.get(url); + return Response.ok(result.getContent()).build(); + } catch (Exception e) { + return Response.status(Status.SERVICE_UNAVAILABLE).entity(e.getMessage()).build(); + } + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/resource/UserREST.java b/commafeed-server/src/main/java/com/commafeed/frontend/resource/UserREST.java index e6fe3350..12543829 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/resource/UserREST.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/resource/UserREST.java @@ -1,369 +1,369 @@ -package com.commafeed.frontend.resource; - -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.Collections; -import java.util.Optional; -import java.util.UUID; - -import jakarta.annotation.security.PermitAll; -import jakarta.annotation.security.RolesAllowed; -import jakarta.inject.Singleton; -import jakarta.transaction.Transactional; -import jakarta.validation.Valid; -import jakarta.ws.rs.BadRequestException; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.QueryParam; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.Response.Status; -import jakarta.ws.rs.core.UriInfo; - -import org.apache.commons.lang3.RandomStringUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.hc.core5.net.URIBuilder; - -import com.commafeed.CommaFeedApplication; -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.Digests; -import com.commafeed.backend.dao.UserDAO; -import com.commafeed.backend.dao.UserRoleDAO; -import com.commafeed.backend.dao.UserSettingsDAO; -import com.commafeed.backend.feed.FeedUtils; -import com.commafeed.backend.model.User; -import com.commafeed.backend.model.UserRole; -import com.commafeed.backend.model.UserRole.Role; -import com.commafeed.backend.model.UserSettings; -import com.commafeed.backend.model.UserSettings.IconDisplayMode; -import com.commafeed.backend.model.UserSettings.ReadingMode; -import com.commafeed.backend.model.UserSettings.ReadingOrder; -import com.commafeed.backend.model.UserSettings.ScrollMode; -import com.commafeed.backend.service.MailService; -import com.commafeed.backend.service.PasswordEncryptionService; -import com.commafeed.backend.service.UserService; -import com.commafeed.frontend.model.Settings; -import com.commafeed.frontend.model.UserModel; -import com.commafeed.frontend.model.request.PasswordResetRequest; -import com.commafeed.frontend.model.request.ProfileModificationRequest; -import com.commafeed.frontend.model.request.RegistrationRequest; -import com.commafeed.security.AuthenticationContext; -import com.commafeed.security.Roles; -import com.google.common.base.Preconditions; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Path("/rest/user") -@RolesAllowed(Roles.USER) -@Produces(MediaType.APPLICATION_JSON) -@Consumes(MediaType.APPLICATION_JSON) -@Slf4j -@RequiredArgsConstructor -@Singleton -@Tag(name = "Users") -public class UserREST { - - private final AuthenticationContext authenticationContext; - private final UserDAO userDAO; - private final UserRoleDAO userRoleDAO; - private final UserSettingsDAO userSettingsDAO; - private final UserService userService; - private final PasswordEncryptionService encryptionService; - private final MailService mailService; - private final CommaFeedConfiguration config; - private final UriInfo uri; - - @Path("/settings") - @GET - @Transactional - @Operation( - summary = "Retrieve user settings", - description = "Retrieve user settings", - responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Settings.class))) }) - public Response getUserSettings() { - Settings s = new Settings(); - - User user = authenticationContext.getCurrentUser(); - UserSettings settings = userSettingsDAO.findByUser(user); - if (settings != null) { - s.setReadingMode(settings.getReadingMode().name()); - s.setReadingOrder(settings.getReadingOrder().name()); - s.setShowRead(settings.isShowRead()); - - s.getSharingSettings().setEmail(settings.isEmail()); - s.getSharingSettings().setGmail(settings.isGmail()); - s.getSharingSettings().setFacebook(settings.isFacebook()); - s.getSharingSettings().setTwitter(settings.isTwitter()); - s.getSharingSettings().setTumblr(settings.isTumblr()); - s.getSharingSettings().setPocket(settings.isPocket()); - s.getSharingSettings().setInstapaper(settings.isInstapaper()); - s.getSharingSettings().setBuffer(settings.isBuffer()); - - s.setScrollMarks(settings.isScrollMarks()); - s.setCustomCss(settings.getCustomCss()); - s.setCustomJs(settings.getCustomJs()); - s.setLanguage(settings.getLanguage()); - s.setScrollSpeed(settings.getScrollSpeed()); - s.setScrollMode(settings.getScrollMode().name()); - s.setEntriesToKeepOnTopWhenScrolling(settings.getEntriesToKeepOnTopWhenScrolling()); - s.setStarIconDisplayMode(settings.getStarIconDisplayMode().name()); - s.setExternalLinkIconDisplayMode(settings.getExternalLinkIconDisplayMode().name()); - s.setMarkAllAsReadConfirmation(settings.isMarkAllAsReadConfirmation()); - s.setCustomContextMenu(settings.isCustomContextMenu()); - s.setMobileFooter(settings.isMobileFooter()); - s.setUnreadCountTitle(settings.isUnreadCountTitle()); - s.setUnreadCountFavicon(settings.isUnreadCountFavicon()); - } else { - s.setReadingMode(ReadingMode.unread.name()); - s.setReadingOrder(ReadingOrder.desc.name()); - s.setShowRead(true); - - s.getSharingSettings().setEmail(true); - s.getSharingSettings().setGmail(true); - s.getSharingSettings().setFacebook(true); - s.getSharingSettings().setTwitter(true); - s.getSharingSettings().setTumblr(true); - s.getSharingSettings().setPocket(true); - s.getSharingSettings().setInstapaper(true); - s.getSharingSettings().setBuffer(true); - - s.setScrollMarks(true); - s.setLanguage("en"); - s.setScrollSpeed(400); - s.setScrollMode(ScrollMode.if_needed.name()); - s.setEntriesToKeepOnTopWhenScrolling(0); - s.setStarIconDisplayMode(IconDisplayMode.on_desktop.name()); - s.setExternalLinkIconDisplayMode(IconDisplayMode.on_desktop.name()); - s.setMarkAllAsReadConfirmation(true); - s.setCustomContextMenu(true); - s.setMobileFooter(false); - s.setUnreadCountTitle(false); - s.setUnreadCountFavicon(true); - } - return Response.ok(s).build(); - } - - @Path("/settings") - @POST - @Transactional - @Operation(summary = "Save user settings", description = "Save user settings") - public Response saveUserSettings(@Parameter(required = true) Settings settings) { - Preconditions.checkNotNull(settings); - - User user = authenticationContext.getCurrentUser(); - UserSettings s = userSettingsDAO.findByUser(user); - if (s == null) { - s = new UserSettings(); - s.setUser(user); - } - s.setReadingMode(ReadingMode.valueOf(settings.getReadingMode())); - s.setReadingOrder(ReadingOrder.valueOf(settings.getReadingOrder())); - s.setShowRead(settings.isShowRead()); - s.setScrollMarks(settings.isScrollMarks()); - s.setCustomCss(settings.getCustomCss()); - s.setCustomJs(CommaFeedApplication.USERNAME_DEMO.equals(user.getName()) ? "" : settings.getCustomJs()); - s.setLanguage(settings.getLanguage()); - s.setScrollSpeed(settings.getScrollSpeed()); - s.setScrollMode(ScrollMode.valueOf(settings.getScrollMode())); - s.setEntriesToKeepOnTopWhenScrolling(settings.getEntriesToKeepOnTopWhenScrolling()); - s.setStarIconDisplayMode(IconDisplayMode.valueOf(settings.getStarIconDisplayMode())); - s.setExternalLinkIconDisplayMode(IconDisplayMode.valueOf(settings.getExternalLinkIconDisplayMode())); - s.setMarkAllAsReadConfirmation(settings.isMarkAllAsReadConfirmation()); - s.setCustomContextMenu(settings.isCustomContextMenu()); - s.setMobileFooter(settings.isMobileFooter()); - s.setUnreadCountTitle(settings.isUnreadCountTitle()); - s.setUnreadCountFavicon(settings.isUnreadCountFavicon()); - - s.setEmail(settings.getSharingSettings().isEmail()); - s.setGmail(settings.getSharingSettings().isGmail()); - s.setFacebook(settings.getSharingSettings().isFacebook()); - s.setTwitter(settings.getSharingSettings().isTwitter()); - s.setTumblr(settings.getSharingSettings().isTumblr()); - s.setPocket(settings.getSharingSettings().isPocket()); - s.setInstapaper(settings.getSharingSettings().isInstapaper()); - s.setBuffer(settings.getSharingSettings().isBuffer()); - - userSettingsDAO.saveOrUpdate(s); - return Response.ok().build(); - - } - - @Path("/profile") - @GET - @Transactional - @Operation( - summary = "Retrieve user's profile", - responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = UserModel.class))) }) - public Response getUserProfile() { - User user = authenticationContext.getCurrentUser(); - - UserModel userModel = new UserModel(); - userModel.setId(user.getId()); - userModel.setName(user.getName()); - userModel.setEmail(user.getEmail()); - userModel.setEnabled(!user.isDisabled()); - userModel.setApiKey(user.getApiKey()); - userModel.setLastForceRefresh(user.getLastForceRefresh()); - for (UserRole role : userRoleDAO.findAll(user)) { - if (role.getRole() == Role.ADMIN) { - userModel.setAdmin(true); - } - } - return Response.ok(userModel).build(); - } - - @Path("/profile") - @POST - @Transactional - @Operation(summary = "Save user's profile") - public Response saveUserProfile(@Valid @Parameter(required = true) ProfileModificationRequest request) { - User user = authenticationContext.getCurrentUser(); - if (CommaFeedApplication.USERNAME_DEMO.equals(user.getName())) { - return Response.status(Status.FORBIDDEN).build(); - } - - Optional login = userService.login(user.getName(), request.getCurrentPassword()); - if (login.isEmpty()) { - throw new BadRequestException("invalid password"); - } - - String email = StringUtils.trimToNull(request.getEmail()); - if (StringUtils.isNotBlank(email)) { - User u = userDAO.findByEmail(email); - if (u != null && !user.getId().equals(u.getId())) { - throw new BadRequestException("email already taken"); - } - user.setEmail(email); - } - - if (StringUtils.isNotBlank(request.getNewPassword())) { - byte[] password = encryptionService.getEncryptedPassword(request.getNewPassword(), user.getSalt()); - user.setPassword(password); - user.setApiKey(userService.generateApiKey(user)); - } - - if (request.isNewApiKey()) { - user.setApiKey(userService.generateApiKey(user)); - } - - userDAO.saveOrUpdate(user); - return Response.ok().build(); - } - - @Path("/register") - @PermitAll - @POST - @Transactional - @Operation(summary = "Register a new account") - public Response registerUser(@Valid @Parameter(required = true) RegistrationRequest req) { - try { - userService.register(req.getName(), req.getPassword(), req.getEmail(), Collections.singletonList(Role.USER)); - return Response.ok().build(); - } catch (final IllegalArgumentException e) { - throw new BadRequestException(e.getMessage()); - } - } - - @Path("/passwordReset") - @PermitAll - @POST - @Transactional - @Operation(summary = "send a password reset email") - public Response sendPasswordReset(@Valid @Parameter(required = true) PasswordResetRequest req) { - if (!config.passwordRecoveryEnabled()) { - throw new IllegalArgumentException("Password recovery is not enabled on this CommaFeed instance"); - } - - User user = userDAO.findByEmail(req.getEmail()); - if (user == null) { - return Response.ok().build(); - } - - try { - user.setRecoverPasswordToken(Digests.sha1Hex(UUID.randomUUID().toString())); - user.setRecoverPasswordTokenDate(Instant.now()); - userDAO.saveOrUpdate(user); - mailService.sendMail(user, "Password recovery", buildEmailContent(user)); - return Response.ok().build(); - } catch (Exception e) { - log.error(e.getMessage(), e); - return Response.status(Status.INTERNAL_SERVER_ERROR).entity("could not send email").type(MediaType.TEXT_PLAIN).build(); - } - } - - private String buildEmailContent(User user) throws Exception { - String publicUrl = FeedUtils.removeTrailingSlash(uri.getBaseUri().toString()); - publicUrl += "/rest/user/passwordResetCallback"; - return String.format( - "You asked for password recovery for account '%s', follow this link to change your password. Ignore this if you didn't request a password recovery.", - user.getName(), callbackUrl(user, publicUrl)); - } - - private String callbackUrl(User user, String publicUrl) throws Exception { - return new URIBuilder(publicUrl).addParameter("email", user.getEmail()) - .addParameter("token", user.getRecoverPasswordToken()) - .build() - .toURL() - .toString(); - } - - @Path("/passwordResetCallback") - @PermitAll - @GET - @Transactional - @Produces(MediaType.TEXT_HTML) - public Response passwordRecoveryCallback(@Parameter(required = true) @QueryParam("email") String email, - @Parameter(required = true) @QueryParam("token") String token) { - Preconditions.checkNotNull(email); - Preconditions.checkNotNull(token); - - User user = userDAO.findByEmail(email); - if (user == null) { - return Response.status(Status.UNAUTHORIZED).entity("Email not found.").build(); - } - if (user.getRecoverPasswordToken() == null || !user.getRecoverPasswordToken().equals(token)) { - return Response.status(Status.UNAUTHORIZED).entity("Invalid token.").build(); - } - if (ChronoUnit.DAYS.between(user.getRecoverPasswordTokenDate(), Instant.now()) >= 2) { - return Response.status(Status.UNAUTHORIZED).entity("token expired.").build(); - } - - String passwd = RandomStringUtils.secure().nextAlphanumeric(10); - byte[] encryptedPassword = encryptionService.getEncryptedPassword(passwd, user.getSalt()); - user.setPassword(encryptedPassword); - if (StringUtils.isNotBlank(user.getApiKey())) { - user.setApiKey(userService.generateApiKey(user)); - } - user.setRecoverPasswordToken(null); - user.setRecoverPasswordTokenDate(null); - userDAO.saveOrUpdate(user); - - String message = "Your new password is: " + passwd; - message += "
"; - message += String.format("Back to Homepage", uri.getBaseUri()); - return Response.ok(message).build(); - } - - @Path("/profile/deleteAccount") - @POST - @Transactional - @Operation(summary = "Delete the user account") - public Response deleteUser() { - User user = authenticationContext.getCurrentUser(); - if (CommaFeedApplication.USERNAME_ADMIN.equals(user.getName()) || CommaFeedApplication.USERNAME_DEMO.equals(user.getName())) { - return Response.status(Status.FORBIDDEN).build(); - } - userService.unregister(userDAO.findById(user.getId())); - return Response.ok().build(); - } -} +package com.commafeed.frontend.resource; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.Optional; +import java.util.UUID; + +import jakarta.annotation.security.PermitAll; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Singleton; +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.core.UriInfo; + +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.hc.core5.net.URIBuilder; + +import com.commafeed.CommaFeedApplication; +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.backend.Digests; +import com.commafeed.backend.dao.UserDAO; +import com.commafeed.backend.dao.UserRoleDAO; +import com.commafeed.backend.dao.UserSettingsDAO; +import com.commafeed.backend.feed.FeedUtils; +import com.commafeed.backend.model.User; +import com.commafeed.backend.model.UserRole; +import com.commafeed.backend.model.UserRole.Role; +import com.commafeed.backend.model.UserSettings; +import com.commafeed.backend.model.UserSettings.IconDisplayMode; +import com.commafeed.backend.model.UserSettings.ReadingMode; +import com.commafeed.backend.model.UserSettings.ReadingOrder; +import com.commafeed.backend.model.UserSettings.ScrollMode; +import com.commafeed.backend.service.MailService; +import com.commafeed.backend.service.PasswordEncryptionService; +import com.commafeed.backend.service.UserService; +import com.commafeed.frontend.model.Settings; +import com.commafeed.frontend.model.UserModel; +import com.commafeed.frontend.model.request.PasswordResetRequest; +import com.commafeed.frontend.model.request.ProfileModificationRequest; +import com.commafeed.frontend.model.request.RegistrationRequest; +import com.commafeed.security.AuthenticationContext; +import com.commafeed.security.Roles; +import com.google.common.base.Preconditions; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Path("/rest/user") +@RolesAllowed(Roles.USER) +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Slf4j +@RequiredArgsConstructor +@Singleton +@Tag(name = "Users") +public class UserREST { + + private final AuthenticationContext authenticationContext; + private final UserDAO userDAO; + private final UserRoleDAO userRoleDAO; + private final UserSettingsDAO userSettingsDAO; + private final UserService userService; + private final PasswordEncryptionService encryptionService; + private final MailService mailService; + private final CommaFeedConfiguration config; + private final UriInfo uri; + + @Path("/settings") + @GET + @Transactional + @Operation( + summary = "Retrieve user settings", + description = "Retrieve user settings", + responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Settings.class))) }) + public Response getUserSettings() { + Settings s = new Settings(); + + User user = authenticationContext.getCurrentUser(); + UserSettings settings = userSettingsDAO.findByUser(user); + if (settings != null) { + s.setReadingMode(settings.getReadingMode().name()); + s.setReadingOrder(settings.getReadingOrder().name()); + s.setShowRead(settings.isShowRead()); + + s.getSharingSettings().setEmail(settings.isEmail()); + s.getSharingSettings().setGmail(settings.isGmail()); + s.getSharingSettings().setFacebook(settings.isFacebook()); + s.getSharingSettings().setTwitter(settings.isTwitter()); + s.getSharingSettings().setTumblr(settings.isTumblr()); + s.getSharingSettings().setPocket(settings.isPocket()); + s.getSharingSettings().setInstapaper(settings.isInstapaper()); + s.getSharingSettings().setBuffer(settings.isBuffer()); + + s.setScrollMarks(settings.isScrollMarks()); + s.setCustomCss(settings.getCustomCss()); + s.setCustomJs(settings.getCustomJs()); + s.setLanguage(settings.getLanguage()); + s.setScrollSpeed(settings.getScrollSpeed()); + s.setScrollMode(settings.getScrollMode().name()); + s.setEntriesToKeepOnTopWhenScrolling(settings.getEntriesToKeepOnTopWhenScrolling()); + s.setStarIconDisplayMode(settings.getStarIconDisplayMode().name()); + s.setExternalLinkIconDisplayMode(settings.getExternalLinkIconDisplayMode().name()); + s.setMarkAllAsReadConfirmation(settings.isMarkAllAsReadConfirmation()); + s.setCustomContextMenu(settings.isCustomContextMenu()); + s.setMobileFooter(settings.isMobileFooter()); + s.setUnreadCountTitle(settings.isUnreadCountTitle()); + s.setUnreadCountFavicon(settings.isUnreadCountFavicon()); + } else { + s.setReadingMode(ReadingMode.unread.name()); + s.setReadingOrder(ReadingOrder.desc.name()); + s.setShowRead(true); + + s.getSharingSettings().setEmail(true); + s.getSharingSettings().setGmail(true); + s.getSharingSettings().setFacebook(true); + s.getSharingSettings().setTwitter(true); + s.getSharingSettings().setTumblr(true); + s.getSharingSettings().setPocket(true); + s.getSharingSettings().setInstapaper(true); + s.getSharingSettings().setBuffer(true); + + s.setScrollMarks(true); + s.setLanguage("en"); + s.setScrollSpeed(400); + s.setScrollMode(ScrollMode.if_needed.name()); + s.setEntriesToKeepOnTopWhenScrolling(0); + s.setStarIconDisplayMode(IconDisplayMode.on_desktop.name()); + s.setExternalLinkIconDisplayMode(IconDisplayMode.on_desktop.name()); + s.setMarkAllAsReadConfirmation(true); + s.setCustomContextMenu(true); + s.setMobileFooter(false); + s.setUnreadCountTitle(false); + s.setUnreadCountFavicon(true); + } + return Response.ok(s).build(); + } + + @Path("/settings") + @POST + @Transactional + @Operation(summary = "Save user settings", description = "Save user settings") + public Response saveUserSettings(@Parameter(required = true) Settings settings) { + Preconditions.checkNotNull(settings); + + User user = authenticationContext.getCurrentUser(); + UserSettings s = userSettingsDAO.findByUser(user); + if (s == null) { + s = new UserSettings(); + s.setUser(user); + } + s.setReadingMode(ReadingMode.valueOf(settings.getReadingMode())); + s.setReadingOrder(ReadingOrder.valueOf(settings.getReadingOrder())); + s.setShowRead(settings.isShowRead()); + s.setScrollMarks(settings.isScrollMarks()); + s.setCustomCss(settings.getCustomCss()); + s.setCustomJs(CommaFeedApplication.USERNAME_DEMO.equals(user.getName()) ? "" : settings.getCustomJs()); + s.setLanguage(settings.getLanguage()); + s.setScrollSpeed(settings.getScrollSpeed()); + s.setScrollMode(ScrollMode.valueOf(settings.getScrollMode())); + s.setEntriesToKeepOnTopWhenScrolling(settings.getEntriesToKeepOnTopWhenScrolling()); + s.setStarIconDisplayMode(IconDisplayMode.valueOf(settings.getStarIconDisplayMode())); + s.setExternalLinkIconDisplayMode(IconDisplayMode.valueOf(settings.getExternalLinkIconDisplayMode())); + s.setMarkAllAsReadConfirmation(settings.isMarkAllAsReadConfirmation()); + s.setCustomContextMenu(settings.isCustomContextMenu()); + s.setMobileFooter(settings.isMobileFooter()); + s.setUnreadCountTitle(settings.isUnreadCountTitle()); + s.setUnreadCountFavicon(settings.isUnreadCountFavicon()); + + s.setEmail(settings.getSharingSettings().isEmail()); + s.setGmail(settings.getSharingSettings().isGmail()); + s.setFacebook(settings.getSharingSettings().isFacebook()); + s.setTwitter(settings.getSharingSettings().isTwitter()); + s.setTumblr(settings.getSharingSettings().isTumblr()); + s.setPocket(settings.getSharingSettings().isPocket()); + s.setInstapaper(settings.getSharingSettings().isInstapaper()); + s.setBuffer(settings.getSharingSettings().isBuffer()); + + userSettingsDAO.saveOrUpdate(s); + return Response.ok().build(); + + } + + @Path("/profile") + @GET + @Transactional + @Operation( + summary = "Retrieve user's profile", + responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = UserModel.class))) }) + public Response getUserProfile() { + User user = authenticationContext.getCurrentUser(); + + UserModel userModel = new UserModel(); + userModel.setId(user.getId()); + userModel.setName(user.getName()); + userModel.setEmail(user.getEmail()); + userModel.setEnabled(!user.isDisabled()); + userModel.setApiKey(user.getApiKey()); + userModel.setLastForceRefresh(user.getLastForceRefresh()); + for (UserRole role : userRoleDAO.findAll(user)) { + if (role.getRole() == Role.ADMIN) { + userModel.setAdmin(true); + } + } + return Response.ok(userModel).build(); + } + + @Path("/profile") + @POST + @Transactional + @Operation(summary = "Save user's profile") + public Response saveUserProfile(@Valid @Parameter(required = true) ProfileModificationRequest request) { + User user = authenticationContext.getCurrentUser(); + if (CommaFeedApplication.USERNAME_DEMO.equals(user.getName())) { + return Response.status(Status.FORBIDDEN).build(); + } + + Optional login = userService.login(user.getName(), request.getCurrentPassword()); + if (login.isEmpty()) { + throw new BadRequestException("invalid password"); + } + + String email = StringUtils.trimToNull(request.getEmail()); + if (StringUtils.isNotBlank(email)) { + User u = userDAO.findByEmail(email); + if (u != null && !user.getId().equals(u.getId())) { + throw new BadRequestException("email already taken"); + } + user.setEmail(email); + } + + if (StringUtils.isNotBlank(request.getNewPassword())) { + byte[] password = encryptionService.getEncryptedPassword(request.getNewPassword(), user.getSalt()); + user.setPassword(password); + user.setApiKey(userService.generateApiKey(user)); + } + + if (request.isNewApiKey()) { + user.setApiKey(userService.generateApiKey(user)); + } + + userDAO.saveOrUpdate(user); + return Response.ok().build(); + } + + @Path("/register") + @PermitAll + @POST + @Transactional + @Operation(summary = "Register a new account") + public Response registerUser(@Valid @Parameter(required = true) RegistrationRequest req) { + try { + userService.register(req.getName(), req.getPassword(), req.getEmail(), Collections.singletonList(Role.USER)); + return Response.ok().build(); + } catch (final IllegalArgumentException e) { + throw new BadRequestException(e.getMessage()); + } + } + + @Path("/passwordReset") + @PermitAll + @POST + @Transactional + @Operation(summary = "send a password reset email") + public Response sendPasswordReset(@Valid @Parameter(required = true) PasswordResetRequest req) { + if (!config.passwordRecoveryEnabled()) { + throw new IllegalArgumentException("Password recovery is not enabled on this CommaFeed instance"); + } + + User user = userDAO.findByEmail(req.getEmail()); + if (user == null) { + return Response.ok().build(); + } + + try { + user.setRecoverPasswordToken(Digests.sha1Hex(UUID.randomUUID().toString())); + user.setRecoverPasswordTokenDate(Instant.now()); + userDAO.saveOrUpdate(user); + mailService.sendMail(user, "Password recovery", buildEmailContent(user)); + return Response.ok().build(); + } catch (Exception e) { + log.error(e.getMessage(), e); + return Response.status(Status.INTERNAL_SERVER_ERROR).entity("could not send email").type(MediaType.TEXT_PLAIN).build(); + } + } + + private String buildEmailContent(User user) throws Exception { + String publicUrl = FeedUtils.removeTrailingSlash(uri.getBaseUri().toString()); + publicUrl += "/rest/user/passwordResetCallback"; + return String.format( + "You asked for password recovery for account '%s', follow this link to change your password. Ignore this if you didn't request a password recovery.", + user.getName(), callbackUrl(user, publicUrl)); + } + + private String callbackUrl(User user, String publicUrl) throws Exception { + return new URIBuilder(publicUrl).addParameter("email", user.getEmail()) + .addParameter("token", user.getRecoverPasswordToken()) + .build() + .toURL() + .toString(); + } + + @Path("/passwordResetCallback") + @PermitAll + @GET + @Transactional + @Produces(MediaType.TEXT_HTML) + public Response passwordRecoveryCallback(@Parameter(required = true) @QueryParam("email") String email, + @Parameter(required = true) @QueryParam("token") String token) { + Preconditions.checkNotNull(email); + Preconditions.checkNotNull(token); + + User user = userDAO.findByEmail(email); + if (user == null) { + return Response.status(Status.UNAUTHORIZED).entity("Email not found.").build(); + } + if (user.getRecoverPasswordToken() == null || !user.getRecoverPasswordToken().equals(token)) { + return Response.status(Status.UNAUTHORIZED).entity("Invalid token.").build(); + } + if (ChronoUnit.DAYS.between(user.getRecoverPasswordTokenDate(), Instant.now()) >= 2) { + return Response.status(Status.UNAUTHORIZED).entity("token expired.").build(); + } + + String passwd = RandomStringUtils.secure().nextAlphanumeric(10); + byte[] encryptedPassword = encryptionService.getEncryptedPassword(passwd, user.getSalt()); + user.setPassword(encryptedPassword); + if (StringUtils.isNotBlank(user.getApiKey())) { + user.setApiKey(userService.generateApiKey(user)); + } + user.setRecoverPasswordToken(null); + user.setRecoverPasswordTokenDate(null); + userDAO.saveOrUpdate(user); + + String message = "Your new password is: " + passwd; + message += "
"; + message += String.format("Back to Homepage", uri.getBaseUri()); + return Response.ok(message).build(); + } + + @Path("/profile/deleteAccount") + @POST + @Transactional + @Operation(summary = "Delete the user account") + public Response deleteUser() { + User user = authenticationContext.getCurrentUser(); + if (CommaFeedApplication.USERNAME_ADMIN.equals(user.getName()) || CommaFeedApplication.USERNAME_DEMO.equals(user.getName())) { + return Response.status(Status.FORBIDDEN).build(); + } + userService.unregister(userDAO.findById(user.getId())); + return Response.ok().build(); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/resource/fever/FeverREST.java b/commafeed-server/src/main/java/com/commafeed/frontend/resource/fever/FeverREST.java index 978dd871..3a498e93 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/resource/fever/FeverREST.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/resource/fever/FeverREST.java @@ -1,331 +1,331 @@ -package com.commafeed.frontend.resource.fever; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.Base64; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import jakarta.annotation.security.PermitAll; -import jakarta.inject.Singleton; -import jakarta.transaction.Transactional; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.PathParam; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.core.Context; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.MultivaluedMap; -import jakarta.ws.rs.core.UriInfo; - -import org.jboss.resteasy.reactive.server.multipart.FormValue; -import org.jboss.resteasy.reactive.server.multipart.MultipartFormDataInput; - -import com.commafeed.backend.dao.FeedCategoryDAO; -import com.commafeed.backend.dao.FeedEntryDAO; -import com.commafeed.backend.dao.FeedEntryStatusDAO; -import com.commafeed.backend.dao.FeedSubscriptionDAO; -import com.commafeed.backend.favicon.Favicon; -import com.commafeed.backend.model.Feed; -import com.commafeed.backend.model.FeedCategory; -import com.commafeed.backend.model.FeedEntry; -import com.commafeed.backend.model.FeedEntryStatus; -import com.commafeed.backend.model.FeedSubscription; -import com.commafeed.backend.model.User; -import com.commafeed.backend.model.UserSettings.ReadingOrder; -import com.commafeed.backend.service.FeedEntryService; -import com.commafeed.backend.service.FeedService; -import com.commafeed.backend.service.UserService; -import com.commafeed.frontend.resource.fever.FeverResponse.FeverFavicon; -import com.commafeed.frontend.resource.fever.FeverResponse.FeverFeed; -import com.commafeed.frontend.resource.fever.FeverResponse.FeverFeedGroup; -import com.commafeed.frontend.resource.fever.FeverResponse.FeverGroup; -import com.commafeed.frontend.resource.fever.FeverResponse.FeverItem; - -import io.swagger.v3.oas.annotations.Hidden; -import lombok.RequiredArgsConstructor; - -/** - * Fever-compatible API - * - *
    - *
  • url: /rest/fever/user/${userId}
  • - *
  • login: username
  • - *
  • password: api key
  • - *
- * - * See https://feedafever.com/api - */ -@Path("/rest/fever") -@PermitAll -@Produces(MediaType.APPLICATION_JSON) -@RequiredArgsConstructor -@Singleton -@Hidden -public class FeverREST { - - private static final String PATH = "/user/{userId}{optionalTrailingFever : (/fever)?}{optionalTrailingSlash : (/)?}"; - private static final int UNREAD_ITEM_IDS_BATCH_SIZE = 1000; - private static final int SAVED_ITEM_IDS_BATCH_SIZE = 1000; - private static final int ITEMS_BATCH_SIZE = 200; - - private final UserService userService; - private final FeedEntryService feedEntryService; - private final FeedService feedService; - private final FeedEntryDAO feedEntryDAO; - private final FeedSubscriptionDAO feedSubscriptionDAO; - private final FeedCategoryDAO feedCategoryDAO; - private final FeedEntryStatusDAO feedEntryStatusDAO; - - // expected Fever API - @Consumes(MediaType.APPLICATION_FORM_URLENCODED) - @Path(PATH) - @POST - @Transactional - public FeverResponse formUrlencoded(@Context UriInfo uri, @PathParam("userId") Long userId, MultivaluedMap form) { - Map params = new HashMap<>(); - uri.getQueryParameters().forEach((k, v) -> params.put(k, v.get(0))); - form.forEach((k, v) -> params.put(k, v.get(0))); - return handle(userId, params); - } - - // workaround for some readers that post data without any media type, and all params in the url - // e.g. FeedMe - @Path(PATH) - @POST - @Transactional - public FeverResponse noForm(@Context UriInfo uri, @PathParam("userId") Long userId) { - Map params = new HashMap<>(); - uri.getQueryParameters().forEach((k, v) -> params.put(k, v.get(0))); - return handle(userId, params); - } - - // workaround for some readers that use GET instead of POST - // e.g. Unread - @Path(PATH) - @GET - @Transactional - public FeverResponse get(@Context UriInfo uri, @PathParam("userId") Long userId) { - Map params = new HashMap<>(); - uri.getQueryParameters().forEach((k, v) -> params.put(k, v.get(0))); - return handle(userId, params); - } - - // workaround for some readers that post data using MultiPart FormData instead of the classic POST - // e.g. Raven Reader - @Consumes(MediaType.MULTIPART_FORM_DATA) - @Path(PATH) - @POST - @Transactional - public FeverResponse formData(@Context UriInfo uri, @PathParam("userId") Long userId, MultipartFormDataInput form) { - Map params = new HashMap<>(); - uri.getQueryParameters().forEach((k, v) -> params.put(k, v.get(0))); - form.getValues().forEach((k, v) -> params.put(k, v.stream().map(FormValue::getValue).findFirst().orElse(null))); - return handle(userId, params); - } - - public FeverResponse handle(long userId, Map params) { - User user = auth(userId, params.get("api_key")).orElse(null); - if (user == null) { - FeverResponse resp = new FeverResponse(); - resp.setAuth(false); - return resp; - } - - FeverResponse resp = new FeverResponse(); - resp.setAuth(true); - - List subscriptions = feedSubscriptionDAO.findAll(user); - resp.setLastRefreshedOnTime(buildLastRefreshedOnTime(subscriptions)); - - if (params.containsKey("groups") || params.containsKey("feeds")) { - resp.setFeedsGroups(buildFeedsGroups(subscriptions)); - - if (params.containsKey("groups")) { - List categories = feedCategoryDAO.findAll(user); - resp.setGroups(buildGroups(categories)); - } - - if (params.containsKey("feeds")) { - resp.setFeeds(buildFeeds(subscriptions)); - } - } - - if (params.containsKey("unread_item_ids")) { - resp.setUnreadItemIds(buildUnreadItemIds(user, subscriptions)); - } - - if (params.containsKey("saved_item_ids")) { - resp.setSavedItemIds(buildSavedItemIds(user)); - } - - if (params.containsKey("items")) { - if (params.containsKey("with_ids")) { - String withIds = params.get("with_ids"); - List entryIds = Stream.of(withIds.split(",")).map(String::trim).toList(); - resp.setItems(buildItems(user, subscriptions, entryIds)); - } else { - Long sinceId = params.containsKey("since_id") ? Long.valueOf(params.get("since_id")) : null; - Long maxId = params.containsKey("max_id") ? Long.valueOf(params.get("max_id")) : null; - resp.setItems(buildItems(user, subscriptions, sinceId, maxId)); - } - } - - if (params.containsKey("favicons")) { - resp.setFavicons(buildFavicons(subscriptions)); - } - - if (params.containsKey("links")) { - resp.setLinks(Collections.emptyList()); - } - - if (params.containsKey("mark") && params.containsKey("id") && params.containsKey("as")) { - long id = Long.parseLong(params.get("id")); - String before = params.get("before"); - Instant insertedBefore = before == null ? null : Instant.ofEpochSecond(Long.parseLong(before)); - mark(user, params.get("mark"), id, params.get("as"), insertedBefore); - } - - return resp; - } - - private Optional auth(Long userId, String feverApiKey) { - return userService.login(userId, feverApiKey); - } - - private long buildLastRefreshedOnTime(List subscriptions) { - return subscriptions.stream() - .map(FeedSubscription::getFeed) - .map(Feed::getLastUpdated) - .filter(Objects::nonNull) - .max(Comparator.naturalOrder()) - .map(Instant::getEpochSecond) - .orElse(0L); - } - - private List buildFeedsGroups(List subscriptions) { - return subscriptions.stream() - .collect(Collectors.groupingBy(s -> s.getCategory() == null ? 0 : s.getCategory().getId())) - .entrySet() - .stream() - .map(e -> { - FeverFeedGroup fg = new FeverFeedGroup(); - fg.setGroupId(e.getKey()); - fg.setFeedIds(e.getValue().stream().map(FeedSubscription::getId).toList()); - return fg; - }) - .toList(); - } - - private List buildGroups(List categories) { - return categories.stream().map(c -> { - FeverGroup g = new FeverGroup(); - g.setId(c.getId()); - g.setTitle(c.getName()); - return g; - }).toList(); - } - - private List buildFeeds(List subscriptions) { - return subscriptions.stream().map(s -> { - FeverFeed f = new FeverFeed(); - f.setId(s.getId()); - f.setFaviconId(s.getId()); - f.setTitle(s.getTitle()); - f.setUrl(s.getFeed().getUrl()); - f.setSiteUrl(s.getFeed().getLink()); - f.setSpark(false); - f.setLastUpdatedOnTime(s.getFeed().getLastUpdated() == null ? 0 : s.getFeed().getLastUpdated().getEpochSecond()); - return f; - }).toList(); - } - - private List buildUnreadItemIds(User user, List subscriptions) { - List statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, true, null, null, 0, - UNREAD_ITEM_IDS_BATCH_SIZE, ReadingOrder.desc, false, null, null, null); - return statuses.stream().map(s -> s.getEntry().getId()).toList(); - } - - private List buildSavedItemIds(User user) { - List statuses = feedEntryStatusDAO.findStarred(user, null, 0, SAVED_ITEM_IDS_BATCH_SIZE, ReadingOrder.desc, false); - return statuses.stream().map(s -> s.getEntry().getId()).toList(); - } - - private List buildItems(User user, List subscriptions, List entryIds) { - List items = new ArrayList<>(); - - Map subscriptionsByFeedId = subscriptions.stream() - .collect(Collectors.toMap(s -> s.getFeed().getId(), s -> s)); - for (String entryId : entryIds) { - FeedEntry entry = feedEntryDAO.findById(Long.parseLong(entryId)); - FeedSubscription sub = subscriptionsByFeedId.get(entry.getFeed().getId()); - if (sub != null) { - FeedEntryStatus status = feedEntryStatusDAO.getStatus(user, sub, entry); - items.add(mapStatus(status)); - } - } - - return items; - } - - private List buildItems(User user, List subscriptions, Long sinceId, Long maxId) { - List statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, false, null, null, 0, ITEMS_BATCH_SIZE, - ReadingOrder.desc, false, null, sinceId, maxId); - return statuses.stream().map(this::mapStatus).toList(); - } - - private FeverItem mapStatus(FeedEntryStatus s) { - FeverItem i = new FeverItem(); - i.setId(s.getEntry().getId()); - i.setFeedId(s.getSubscription().getId()); - i.setTitle(s.getEntry().getContent().getTitle()); - i.setAuthor(s.getEntry().getContent().getAuthor()); - i.setHtml(Optional.ofNullable(s.getEntry().getContent().getContent()).orElse("")); - i.setUrl(s.getEntry().getUrl()); - i.setSaved(s.isStarred()); - i.setRead(s.isRead()); - i.setCreatedOnTime(s.getEntryPublished().getEpochSecond()); - return i; - } - - private List buildFavicons(List subscriptions) { - return subscriptions.stream().map(s -> { - Favicon favicon = feedService.fetchFavicon(s.getFeed()); - - FeverFavicon f = new FeverFavicon(); - f.setId(s.getFeed().getId()); - f.setData(String.format("data:%s;base64,%s", favicon.getMediaType(), Base64.getEncoder().encodeToString(favicon.getIcon()))); - return f; - }).toList(); - } - - private void mark(User user, String source, long id, String action, Instant insertedBefore) { - if ("item".equals(source)) { - if ("read".equals(action) || "unread".equals(action)) { - feedEntryService.markEntry(user, id, "read".equals(action)); - } else if ("saved".equals(action) || "unsaved".equals(action)) { - FeedEntry entry = feedEntryDAO.findById(id); - FeedSubscription sub = feedSubscriptionDAO.findByFeed(user, entry.getFeed()); - feedEntryService.starEntry(user, id, sub.getId(), "saved".equals(action)); - } - } else if ("feed".equals(source)) { - FeedSubscription subscription = feedSubscriptionDAO.findById(user, id); - feedEntryService.markSubscriptionEntries(user, Collections.singletonList(subscription), null, insertedBefore, null); - } else if ("group".equals(source)) { - FeedCategory parent = feedCategoryDAO.findById(user, id); - List categories = feedCategoryDAO.findAllChildrenCategories(user, parent); - List subscriptions = feedSubscriptionDAO.findByCategories(user, categories); - feedEntryService.markSubscriptionEntries(user, subscriptions, null, insertedBefore, null); - } - } - -} +package com.commafeed.frontend.resource.fever; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import jakarta.annotation.security.PermitAll; +import jakarta.inject.Singleton; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.UriInfo; + +import org.jboss.resteasy.reactive.server.multipart.FormValue; +import org.jboss.resteasy.reactive.server.multipart.MultipartFormDataInput; + +import com.commafeed.backend.dao.FeedCategoryDAO; +import com.commafeed.backend.dao.FeedEntryDAO; +import com.commafeed.backend.dao.FeedEntryStatusDAO; +import com.commafeed.backend.dao.FeedSubscriptionDAO; +import com.commafeed.backend.favicon.Favicon; +import com.commafeed.backend.model.Feed; +import com.commafeed.backend.model.FeedCategory; +import com.commafeed.backend.model.FeedEntry; +import com.commafeed.backend.model.FeedEntryStatus; +import com.commafeed.backend.model.FeedSubscription; +import com.commafeed.backend.model.User; +import com.commafeed.backend.model.UserSettings.ReadingOrder; +import com.commafeed.backend.service.FeedEntryService; +import com.commafeed.backend.service.FeedService; +import com.commafeed.backend.service.UserService; +import com.commafeed.frontend.resource.fever.FeverResponse.FeverFavicon; +import com.commafeed.frontend.resource.fever.FeverResponse.FeverFeed; +import com.commafeed.frontend.resource.fever.FeverResponse.FeverFeedGroup; +import com.commafeed.frontend.resource.fever.FeverResponse.FeverGroup; +import com.commafeed.frontend.resource.fever.FeverResponse.FeverItem; + +import io.swagger.v3.oas.annotations.Hidden; +import lombok.RequiredArgsConstructor; + +/** + * Fever-compatible API + * + *
    + *
  • url: /rest/fever/user/${userId}
  • + *
  • login: username
  • + *
  • password: api key
  • + *
+ * + * See https://feedafever.com/api + */ +@Path("/rest/fever") +@PermitAll +@Produces(MediaType.APPLICATION_JSON) +@RequiredArgsConstructor +@Singleton +@Hidden +public class FeverREST { + + private static final String PATH = "/user/{userId}{optionalTrailingFever : (/fever)?}{optionalTrailingSlash : (/)?}"; + private static final int UNREAD_ITEM_IDS_BATCH_SIZE = 1000; + private static final int SAVED_ITEM_IDS_BATCH_SIZE = 1000; + private static final int ITEMS_BATCH_SIZE = 200; + + private final UserService userService; + private final FeedEntryService feedEntryService; + private final FeedService feedService; + private final FeedEntryDAO feedEntryDAO; + private final FeedSubscriptionDAO feedSubscriptionDAO; + private final FeedCategoryDAO feedCategoryDAO; + private final FeedEntryStatusDAO feedEntryStatusDAO; + + // expected Fever API + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Path(PATH) + @POST + @Transactional + public FeverResponse formUrlencoded(@Context UriInfo uri, @PathParam("userId") Long userId, MultivaluedMap form) { + Map params = new HashMap<>(); + uri.getQueryParameters().forEach((k, v) -> params.put(k, v.get(0))); + form.forEach((k, v) -> params.put(k, v.get(0))); + return handle(userId, params); + } + + // workaround for some readers that post data without any media type, and all params in the url + // e.g. FeedMe + @Path(PATH) + @POST + @Transactional + public FeverResponse noForm(@Context UriInfo uri, @PathParam("userId") Long userId) { + Map params = new HashMap<>(); + uri.getQueryParameters().forEach((k, v) -> params.put(k, v.get(0))); + return handle(userId, params); + } + + // workaround for some readers that use GET instead of POST + // e.g. Unread + @Path(PATH) + @GET + @Transactional + public FeverResponse get(@Context UriInfo uri, @PathParam("userId") Long userId) { + Map params = new HashMap<>(); + uri.getQueryParameters().forEach((k, v) -> params.put(k, v.get(0))); + return handle(userId, params); + } + + // workaround for some readers that post data using MultiPart FormData instead of the classic POST + // e.g. Raven Reader + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Path(PATH) + @POST + @Transactional + public FeverResponse formData(@Context UriInfo uri, @PathParam("userId") Long userId, MultipartFormDataInput form) { + Map params = new HashMap<>(); + uri.getQueryParameters().forEach((k, v) -> params.put(k, v.get(0))); + form.getValues().forEach((k, v) -> params.put(k, v.stream().map(FormValue::getValue).findFirst().orElse(null))); + return handle(userId, params); + } + + public FeverResponse handle(long userId, Map params) { + User user = auth(userId, params.get("api_key")).orElse(null); + if (user == null) { + FeverResponse resp = new FeverResponse(); + resp.setAuth(false); + return resp; + } + + FeverResponse resp = new FeverResponse(); + resp.setAuth(true); + + List subscriptions = feedSubscriptionDAO.findAll(user); + resp.setLastRefreshedOnTime(buildLastRefreshedOnTime(subscriptions)); + + if (params.containsKey("groups") || params.containsKey("feeds")) { + resp.setFeedsGroups(buildFeedsGroups(subscriptions)); + + if (params.containsKey("groups")) { + List categories = feedCategoryDAO.findAll(user); + resp.setGroups(buildGroups(categories)); + } + + if (params.containsKey("feeds")) { + resp.setFeeds(buildFeeds(subscriptions)); + } + } + + if (params.containsKey("unread_item_ids")) { + resp.setUnreadItemIds(buildUnreadItemIds(user, subscriptions)); + } + + if (params.containsKey("saved_item_ids")) { + resp.setSavedItemIds(buildSavedItemIds(user)); + } + + if (params.containsKey("items")) { + if (params.containsKey("with_ids")) { + String withIds = params.get("with_ids"); + List entryIds = Stream.of(withIds.split(",")).map(String::trim).toList(); + resp.setItems(buildItems(user, subscriptions, entryIds)); + } else { + Long sinceId = params.containsKey("since_id") ? Long.valueOf(params.get("since_id")) : null; + Long maxId = params.containsKey("max_id") ? Long.valueOf(params.get("max_id")) : null; + resp.setItems(buildItems(user, subscriptions, sinceId, maxId)); + } + } + + if (params.containsKey("favicons")) { + resp.setFavicons(buildFavicons(subscriptions)); + } + + if (params.containsKey("links")) { + resp.setLinks(Collections.emptyList()); + } + + if (params.containsKey("mark") && params.containsKey("id") && params.containsKey("as")) { + long id = Long.parseLong(params.get("id")); + String before = params.get("before"); + Instant insertedBefore = before == null ? null : Instant.ofEpochSecond(Long.parseLong(before)); + mark(user, params.get("mark"), id, params.get("as"), insertedBefore); + } + + return resp; + } + + private Optional auth(Long userId, String feverApiKey) { + return userService.login(userId, feverApiKey); + } + + private long buildLastRefreshedOnTime(List subscriptions) { + return subscriptions.stream() + .map(FeedSubscription::getFeed) + .map(Feed::getLastUpdated) + .filter(Objects::nonNull) + .max(Comparator.naturalOrder()) + .map(Instant::getEpochSecond) + .orElse(0L); + } + + private List buildFeedsGroups(List subscriptions) { + return subscriptions.stream() + .collect(Collectors.groupingBy(s -> s.getCategory() == null ? 0 : s.getCategory().getId())) + .entrySet() + .stream() + .map(e -> { + FeverFeedGroup fg = new FeverFeedGroup(); + fg.setGroupId(e.getKey()); + fg.setFeedIds(e.getValue().stream().map(FeedSubscription::getId).toList()); + return fg; + }) + .toList(); + } + + private List buildGroups(List categories) { + return categories.stream().map(c -> { + FeverGroup g = new FeverGroup(); + g.setId(c.getId()); + g.setTitle(c.getName()); + return g; + }).toList(); + } + + private List buildFeeds(List subscriptions) { + return subscriptions.stream().map(s -> { + FeverFeed f = new FeverFeed(); + f.setId(s.getId()); + f.setFaviconId(s.getId()); + f.setTitle(s.getTitle()); + f.setUrl(s.getFeed().getUrl()); + f.setSiteUrl(s.getFeed().getLink()); + f.setSpark(false); + f.setLastUpdatedOnTime(s.getFeed().getLastUpdated() == null ? 0 : s.getFeed().getLastUpdated().getEpochSecond()); + return f; + }).toList(); + } + + private List buildUnreadItemIds(User user, List subscriptions) { + List statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, true, null, null, 0, + UNREAD_ITEM_IDS_BATCH_SIZE, ReadingOrder.desc, false, null, null, null); + return statuses.stream().map(s -> s.getEntry().getId()).toList(); + } + + private List buildSavedItemIds(User user) { + List statuses = feedEntryStatusDAO.findStarred(user, null, 0, SAVED_ITEM_IDS_BATCH_SIZE, ReadingOrder.desc, false); + return statuses.stream().map(s -> s.getEntry().getId()).toList(); + } + + private List buildItems(User user, List subscriptions, List entryIds) { + List items = new ArrayList<>(); + + Map subscriptionsByFeedId = subscriptions.stream() + .collect(Collectors.toMap(s -> s.getFeed().getId(), s -> s)); + for (String entryId : entryIds) { + FeedEntry entry = feedEntryDAO.findById(Long.parseLong(entryId)); + FeedSubscription sub = subscriptionsByFeedId.get(entry.getFeed().getId()); + if (sub != null) { + FeedEntryStatus status = feedEntryStatusDAO.getStatus(user, sub, entry); + items.add(mapStatus(status)); + } + } + + return items; + } + + private List buildItems(User user, List subscriptions, Long sinceId, Long maxId) { + List statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, false, null, null, 0, ITEMS_BATCH_SIZE, + ReadingOrder.desc, false, null, sinceId, maxId); + return statuses.stream().map(this::mapStatus).toList(); + } + + private FeverItem mapStatus(FeedEntryStatus s) { + FeverItem i = new FeverItem(); + i.setId(s.getEntry().getId()); + i.setFeedId(s.getSubscription().getId()); + i.setTitle(s.getEntry().getContent().getTitle()); + i.setAuthor(s.getEntry().getContent().getAuthor()); + i.setHtml(Optional.ofNullable(s.getEntry().getContent().getContent()).orElse("")); + i.setUrl(s.getEntry().getUrl()); + i.setSaved(s.isStarred()); + i.setRead(s.isRead()); + i.setCreatedOnTime(s.getEntryPublished().getEpochSecond()); + return i; + } + + private List buildFavicons(List subscriptions) { + return subscriptions.stream().map(s -> { + Favicon favicon = feedService.fetchFavicon(s.getFeed()); + + FeverFavicon f = new FeverFavicon(); + f.setId(s.getFeed().getId()); + f.setData(String.format("data:%s;base64,%s", favicon.getMediaType(), Base64.getEncoder().encodeToString(favicon.getIcon()))); + return f; + }).toList(); + } + + private void mark(User user, String source, long id, String action, Instant insertedBefore) { + if ("item".equals(source)) { + if ("read".equals(action) || "unread".equals(action)) { + feedEntryService.markEntry(user, id, "read".equals(action)); + } else if ("saved".equals(action) || "unsaved".equals(action)) { + FeedEntry entry = feedEntryDAO.findById(id); + FeedSubscription sub = feedSubscriptionDAO.findByFeed(user, entry.getFeed()); + feedEntryService.starEntry(user, id, sub.getId(), "saved".equals(action)); + } + } else if ("feed".equals(source)) { + FeedSubscription subscription = feedSubscriptionDAO.findById(user, id); + feedEntryService.markSubscriptionEntries(user, Collections.singletonList(subscription), null, insertedBefore, null); + } else if ("group".equals(source)) { + FeedCategory parent = feedCategoryDAO.findById(user, id); + List categories = feedCategoryDAO.findAllChildrenCategories(user, parent); + List subscriptions = feedSubscriptionDAO.findByCategories(user, categories); + feedEntryService.markSubscriptionEntries(user, subscriptions, null, insertedBefore, null); + } + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/resource/fever/FeverResponse.java b/commafeed-server/src/main/java/com/commafeed/frontend/resource/fever/FeverResponse.java index 204ced5e..46290715 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/resource/fever/FeverResponse.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/resource/fever/FeverResponse.java @@ -1,178 +1,178 @@ -package com.commafeed.frontend.resource.fever; - -import java.io.IOException; -import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import com.fasterxml.jackson.annotation.JsonFormat; -import com.fasterxml.jackson.annotation.JsonFormat.Shape; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonInclude.Include; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonSerializer; -import com.fasterxml.jackson.databind.SerializerProvider; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; - -import lombok.Data; - -@JsonInclude(Include.NON_NULL) -@Data -public class FeverResponse { - - @JsonProperty("api_version") - private int apiVersion = 3; - - @JsonProperty("auth") - @JsonFormat(shape = Shape.NUMBER) - private boolean auth; - - @JsonProperty("last_refreshed_on_time") - private Long lastRefreshedOnTime; - - @JsonProperty("groups") - private List groups; - - @JsonProperty("feeds") - private List feeds; - - @JsonProperty("feeds_groups") - private List feedsGroups; - - @JsonProperty("unread_item_ids") - @JsonSerialize(using = LongListToCommaSeparatedStringSerializer.class) - @JsonDeserialize(using = CommaSeparatedStringToLongListDeserializer.class) - private List unreadItemIds; - - @JsonProperty("saved_item_ids") - @JsonSerialize(using = LongListToCommaSeparatedStringSerializer.class) - @JsonDeserialize(using = CommaSeparatedStringToLongListDeserializer.class) - private List savedItemIds; - - @JsonProperty("items") - private List items; - - @JsonProperty("favicons") - private List favicons; - - @JsonProperty("links") - private List links; - - @Data - public static class FeverGroup { - - @JsonProperty("id") - private long id; - - @JsonProperty("title") - private String title; - } - - @Data - public static class FeverFeed { - - @JsonProperty("id") - private long id; - - @JsonProperty("favicon_id") - private long faviconId; - - @JsonProperty("title") - private String title; - - @JsonProperty("url") - private String url; - - @JsonProperty("site_url") - private String siteUrl; - - @JsonProperty("is_spark") - @JsonFormat(shape = Shape.NUMBER) - private boolean spark; - - @JsonProperty("last_updated_on_time") - private long lastUpdatedOnTime; - } - - @Data - public static class FeverFeedGroup { - - @JsonProperty("group_id") - private long groupId; - - @JsonProperty("feed_ids") - @JsonSerialize(using = LongListToCommaSeparatedStringSerializer.class) - @JsonDeserialize(using = CommaSeparatedStringToLongListDeserializer.class) - private List feedIds; - } - - @Data - public static class FeverItem { - - @JsonProperty("id") - private long id; - - @JsonProperty("feed_id") - private long feedId; - - @JsonProperty("title") - private String title; - - @JsonProperty("author") - private String author; - - @JsonProperty("html") - private String html; - - @JsonProperty("url") - private String url; - - @JsonProperty("is_saved") - @JsonFormat(shape = Shape.NUMBER) - private boolean saved; - - @JsonProperty("is_read") - @JsonFormat(shape = Shape.NUMBER) - private boolean read; - - @JsonProperty("created_on_time") - private long createdOnTime; - - } - - @Data - public static class FeverFavicon { - - @JsonProperty("id") - private long id; - - @JsonProperty("data") - private String data; - } - - @Data - public static class FeverLink { - - } - - public static class LongListToCommaSeparatedStringSerializer extends JsonSerializer> { - @Override - public void serialize(List input, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException { - String output = input.stream().map(String::valueOf).collect(Collectors.joining(",")); - jsonGenerator.writeObject(output); - } - } - - public static class CommaSeparatedStringToLongListDeserializer extends JsonDeserializer> { - @Override - public List deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { - String value = ctxt.readValue(p, String.class); - return Stream.of(value.split(",")).map(Long::valueOf).toList(); - } - } -} +package com.commafeed.frontend.resource.fever; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonFormat.Shape; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import lombok.Data; + +@JsonInclude(Include.NON_NULL) +@Data +public class FeverResponse { + + @JsonProperty("api_version") + private int apiVersion = 3; + + @JsonProperty("auth") + @JsonFormat(shape = Shape.NUMBER) + private boolean auth; + + @JsonProperty("last_refreshed_on_time") + private Long lastRefreshedOnTime; + + @JsonProperty("groups") + private List groups; + + @JsonProperty("feeds") + private List feeds; + + @JsonProperty("feeds_groups") + private List feedsGroups; + + @JsonProperty("unread_item_ids") + @JsonSerialize(using = LongListToCommaSeparatedStringSerializer.class) + @JsonDeserialize(using = CommaSeparatedStringToLongListDeserializer.class) + private List unreadItemIds; + + @JsonProperty("saved_item_ids") + @JsonSerialize(using = LongListToCommaSeparatedStringSerializer.class) + @JsonDeserialize(using = CommaSeparatedStringToLongListDeserializer.class) + private List savedItemIds; + + @JsonProperty("items") + private List items; + + @JsonProperty("favicons") + private List favicons; + + @JsonProperty("links") + private List links; + + @Data + public static class FeverGroup { + + @JsonProperty("id") + private long id; + + @JsonProperty("title") + private String title; + } + + @Data + public static class FeverFeed { + + @JsonProperty("id") + private long id; + + @JsonProperty("favicon_id") + private long faviconId; + + @JsonProperty("title") + private String title; + + @JsonProperty("url") + private String url; + + @JsonProperty("site_url") + private String siteUrl; + + @JsonProperty("is_spark") + @JsonFormat(shape = Shape.NUMBER) + private boolean spark; + + @JsonProperty("last_updated_on_time") + private long lastUpdatedOnTime; + } + + @Data + public static class FeverFeedGroup { + + @JsonProperty("group_id") + private long groupId; + + @JsonProperty("feed_ids") + @JsonSerialize(using = LongListToCommaSeparatedStringSerializer.class) + @JsonDeserialize(using = CommaSeparatedStringToLongListDeserializer.class) + private List feedIds; + } + + @Data + public static class FeverItem { + + @JsonProperty("id") + private long id; + + @JsonProperty("feed_id") + private long feedId; + + @JsonProperty("title") + private String title; + + @JsonProperty("author") + private String author; + + @JsonProperty("html") + private String html; + + @JsonProperty("url") + private String url; + + @JsonProperty("is_saved") + @JsonFormat(shape = Shape.NUMBER) + private boolean saved; + + @JsonProperty("is_read") + @JsonFormat(shape = Shape.NUMBER) + private boolean read; + + @JsonProperty("created_on_time") + private long createdOnTime; + + } + + @Data + public static class FeverFavicon { + + @JsonProperty("id") + private long id; + + @JsonProperty("data") + private String data; + } + + @Data + public static class FeverLink { + + } + + public static class LongListToCommaSeparatedStringSerializer extends JsonSerializer> { + @Override + public void serialize(List input, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException { + String output = input.stream().map(String::valueOf).collect(Collectors.joining(",")); + jsonGenerator.writeObject(output); + } + } + + public static class CommaSeparatedStringToLongListDeserializer extends JsonDeserializer> { + @Override + public List deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + String value = ctxt.readValue(p, String.class); + return Stream.of(value.split(",")).map(Long::valueOf).toList(); + } + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/servlet/CustomCssServlet.java b/commafeed-server/src/main/java/com/commafeed/frontend/servlet/CustomCssServlet.java index 473bda5c..6c79ff83 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/servlet/CustomCssServlet.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/servlet/CustomCssServlet.java @@ -1,41 +1,41 @@ -package com.commafeed.frontend.servlet; - -import jakarta.inject.Singleton; -import jakarta.transaction.Transactional; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; - -import com.commafeed.backend.dao.UserSettingsDAO; -import com.commafeed.backend.model.User; -import com.commafeed.backend.model.UserSettings; -import com.commafeed.security.AuthenticationContext; - -import lombok.RequiredArgsConstructor; - -@Path("/custom_css.css") -@Produces("text/css") -@RequiredArgsConstructor -@Singleton -public class CustomCssServlet { - - private final AuthenticationContext authenticationContext; - private final UserSettingsDAO userSettingsDAO; - - @GET - @Transactional - public String get() { - User user = authenticationContext.getCurrentUser(); - if (user == null) { - return ""; - } - - UserSettings settings = userSettingsDAO.findByUser(user); - if (settings == null) { - return ""; - } - - return settings.getCustomCss(); - } - -} +package com.commafeed.frontend.servlet; + +import jakarta.inject.Singleton; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; + +import com.commafeed.backend.dao.UserSettingsDAO; +import com.commafeed.backend.model.User; +import com.commafeed.backend.model.UserSettings; +import com.commafeed.security.AuthenticationContext; + +import lombok.RequiredArgsConstructor; + +@Path("/custom_css.css") +@Produces("text/css") +@RequiredArgsConstructor +@Singleton +public class CustomCssServlet { + + private final AuthenticationContext authenticationContext; + private final UserSettingsDAO userSettingsDAO; + + @GET + @Transactional + public String get() { + User user = authenticationContext.getCurrentUser(); + if (user == null) { + return ""; + } + + UserSettings settings = userSettingsDAO.findByUser(user); + if (settings == null) { + return ""; + } + + return settings.getCustomCss(); + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/servlet/CustomJsServlet.java b/commafeed-server/src/main/java/com/commafeed/frontend/servlet/CustomJsServlet.java index 99f92d5e..9ff4ad4b 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/servlet/CustomJsServlet.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/servlet/CustomJsServlet.java @@ -1,41 +1,41 @@ -package com.commafeed.frontend.servlet; - -import jakarta.inject.Singleton; -import jakarta.transaction.Transactional; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; - -import com.commafeed.backend.dao.UserSettingsDAO; -import com.commafeed.backend.model.User; -import com.commafeed.backend.model.UserSettings; -import com.commafeed.security.AuthenticationContext; - -import lombok.RequiredArgsConstructor; - -@Path("/custom_js.js") -@Produces("application/javascript") -@RequiredArgsConstructor -@Singleton -public class CustomJsServlet { - - private final AuthenticationContext authenticationContext; - private final UserSettingsDAO userSettingsDAO; - - @GET - @Transactional - public String get() { - User user = authenticationContext.getCurrentUser(); - if (user == null) { - return ""; - } - - UserSettings settings = userSettingsDAO.findByUser(user); - if (settings == null) { - return ""; - } - - return settings.getCustomJs(); - } - -} +package com.commafeed.frontend.servlet; + +import jakarta.inject.Singleton; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; + +import com.commafeed.backend.dao.UserSettingsDAO; +import com.commafeed.backend.model.User; +import com.commafeed.backend.model.UserSettings; +import com.commafeed.security.AuthenticationContext; + +import lombok.RequiredArgsConstructor; + +@Path("/custom_js.js") +@Produces("application/javascript") +@RequiredArgsConstructor +@Singleton +public class CustomJsServlet { + + private final AuthenticationContext authenticationContext; + private final UserSettingsDAO userSettingsDAO; + + @GET + @Transactional + public String get() { + User user = authenticationContext.getCurrentUser(); + if (user == null) { + return ""; + } + + UserSettings settings = userSettingsDAO.findByUser(user); + if (settings == null) { + return ""; + } + + return settings.getCustomJs(); + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/servlet/LogoutServlet.java b/commafeed-server/src/main/java/com/commafeed/frontend/servlet/LogoutServlet.java index 1ff57563..cffb4a67 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/servlet/LogoutServlet.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/servlet/LogoutServlet.java @@ -1,34 +1,34 @@ -package com.commafeed.frontend.servlet; - -import java.time.Instant; -import java.util.Date; - -import jakarta.annotation.security.PermitAll; -import jakarta.inject.Singleton; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.core.NewCookie; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.UriInfo; - -import org.eclipse.microprofile.config.inject.ConfigProperty; - -@Path("/logout") -@PermitAll -@Singleton -public class LogoutServlet { - - private final UriInfo uri; - private final String cookieName; - - public LogoutServlet(UriInfo uri, @ConfigProperty(name = "quarkus.http.auth.form.cookie-name") String cookieName) { - this.uri = uri; - this.cookieName = cookieName; - } - - @GET - public Response get() { - NewCookie removeCookie = new NewCookie.Builder(cookieName).maxAge(0).expiry(Date.from(Instant.EPOCH)).path("/").build(); - return Response.temporaryRedirect(uri.getBaseUri()).cookie(removeCookie).build(); - } -} +package com.commafeed.frontend.servlet; + +import java.time.Instant; +import java.util.Date; + +import jakarta.annotation.security.PermitAll; +import jakarta.inject.Singleton; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.NewCookie; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + +@Path("/logout") +@PermitAll +@Singleton +public class LogoutServlet { + + private final UriInfo uri; + private final String cookieName; + + public LogoutServlet(UriInfo uri, @ConfigProperty(name = "quarkus.http.auth.form.cookie-name") String cookieName) { + this.uri = uri; + this.cookieName = cookieName; + } + + @GET + public Response get() { + NewCookie removeCookie = new NewCookie.Builder(cookieName).maxAge(0).expiry(Date.from(Instant.EPOCH)).path("/").build(); + return Response.temporaryRedirect(uri.getBaseUri()).cookie(removeCookie).build(); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/servlet/NextUnreadServlet.java b/commafeed-server/src/main/java/com/commafeed/frontend/servlet/NextUnreadServlet.java index f98b21b6..78a98d9d 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/servlet/NextUnreadServlet.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/servlet/NextUnreadServlet.java @@ -1,75 +1,75 @@ -package com.commafeed.frontend.servlet; - -import java.net.URI; -import java.util.List; - -import jakarta.inject.Singleton; -import jakarta.transaction.Transactional; -import jakarta.ws.rs.DefaultValue; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.QueryParam; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.UriInfo; - -import org.apache.commons.lang3.StringUtils; - -import com.commafeed.backend.dao.FeedCategoryDAO; -import com.commafeed.backend.dao.FeedEntryStatusDAO; -import com.commafeed.backend.dao.FeedSubscriptionDAO; -import com.commafeed.backend.model.FeedCategory; -import com.commafeed.backend.model.FeedEntryStatus; -import com.commafeed.backend.model.FeedSubscription; -import com.commafeed.backend.model.User; -import com.commafeed.backend.model.UserSettings.ReadingOrder; -import com.commafeed.backend.service.FeedEntryService; -import com.commafeed.frontend.resource.CategoryREST; -import com.commafeed.security.AuthenticationContext; -import com.google.common.collect.Iterables; - -import lombok.RequiredArgsConstructor; - -@Path("/next") -@RequiredArgsConstructor -@Singleton -public class NextUnreadServlet { - - private final FeedSubscriptionDAO feedSubscriptionDAO; - private final FeedEntryStatusDAO feedEntryStatusDAO; - private final FeedCategoryDAO feedCategoryDAO; - private final FeedEntryService feedEntryService; - private final AuthenticationContext authenticationContext; - private final UriInfo uri; - - @GET - @Transactional - public Response get(@QueryParam("category") String categoryId, @QueryParam("order") @DefaultValue("desc") ReadingOrder order) { - User user = authenticationContext.getCurrentUser(); - if (user == null) { - return Response.temporaryRedirect(uri.getBaseUri()).build(); - } - - FeedEntryStatus s = null; - if (StringUtils.isBlank(categoryId) || CategoryREST.ALL.equals(categoryId)) { - List subs = feedSubscriptionDAO.findAll(user); - List statuses = feedEntryStatusDAO.findBySubscriptions(user, subs, true, null, null, 0, 1, order, true, null, - null, null); - s = Iterables.getFirst(statuses, null); - } else { - FeedCategory category = feedCategoryDAO.findById(user, Long.valueOf(categoryId)); - if (category != null) { - List children = feedCategoryDAO.findAllChildrenCategories(user, category); - List subscriptions = feedSubscriptionDAO.findByCategories(user, children); - List statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, true, null, null, 0, 1, order, - true, null, null, null); - s = Iterables.getFirst(statuses, null); - } - } - if (s != null) { - feedEntryService.markEntry(user, s.getEntry().getId(), true); - } - - String url = s == null ? uri.getBaseUri().toString() : s.getEntry().getUrl(); - return Response.temporaryRedirect(URI.create(url)).build(); - } -} +package com.commafeed.frontend.servlet; + +import java.net.URI; +import java.util.List; + +import jakarta.inject.Singleton; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; + +import org.apache.commons.lang3.StringUtils; + +import com.commafeed.backend.dao.FeedCategoryDAO; +import com.commafeed.backend.dao.FeedEntryStatusDAO; +import com.commafeed.backend.dao.FeedSubscriptionDAO; +import com.commafeed.backend.model.FeedCategory; +import com.commafeed.backend.model.FeedEntryStatus; +import com.commafeed.backend.model.FeedSubscription; +import com.commafeed.backend.model.User; +import com.commafeed.backend.model.UserSettings.ReadingOrder; +import com.commafeed.backend.service.FeedEntryService; +import com.commafeed.frontend.resource.CategoryREST; +import com.commafeed.security.AuthenticationContext; +import com.google.common.collect.Iterables; + +import lombok.RequiredArgsConstructor; + +@Path("/next") +@RequiredArgsConstructor +@Singleton +public class NextUnreadServlet { + + private final FeedSubscriptionDAO feedSubscriptionDAO; + private final FeedEntryStatusDAO feedEntryStatusDAO; + private final FeedCategoryDAO feedCategoryDAO; + private final FeedEntryService feedEntryService; + private final AuthenticationContext authenticationContext; + private final UriInfo uri; + + @GET + @Transactional + public Response get(@QueryParam("category") String categoryId, @QueryParam("order") @DefaultValue("desc") ReadingOrder order) { + User user = authenticationContext.getCurrentUser(); + if (user == null) { + return Response.temporaryRedirect(uri.getBaseUri()).build(); + } + + FeedEntryStatus s = null; + if (StringUtils.isBlank(categoryId) || CategoryREST.ALL.equals(categoryId)) { + List subs = feedSubscriptionDAO.findAll(user); + List statuses = feedEntryStatusDAO.findBySubscriptions(user, subs, true, null, null, 0, 1, order, true, null, + null, null); + s = Iterables.getFirst(statuses, null); + } else { + FeedCategory category = feedCategoryDAO.findById(user, Long.valueOf(categoryId)); + if (category != null) { + List children = feedCategoryDAO.findAllChildrenCategories(user, category); + List subscriptions = feedSubscriptionDAO.findByCategories(user, children); + List statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, true, null, null, 0, 1, order, + true, null, null, null); + s = Iterables.getFirst(statuses, null); + } + } + if (s != null) { + feedEntryService.markEntry(user, s.getEntry().getId(), true); + } + + String url = s == null ? uri.getBaseUri().toString() : s.getEntry().getUrl(); + return Response.temporaryRedirect(URI.create(url)).build(); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/servlet/RobotsTxtDisallowAllServlet.java b/commafeed-server/src/main/java/com/commafeed/frontend/servlet/RobotsTxtDisallowAllServlet.java index a558c448..4abd5e3d 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/servlet/RobotsTxtDisallowAllServlet.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/servlet/RobotsTxtDisallowAllServlet.java @@ -1,34 +1,34 @@ -package com.commafeed.frontend.servlet; - -import jakarta.annotation.security.PermitAll; -import jakarta.inject.Singleton; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; - -import org.apache.hc.core5.http.HttpStatus; - -import com.commafeed.CommaFeedConfiguration; - -import lombok.RequiredArgsConstructor; - -@Path("/robots.txt") -@PermitAll -@Produces(MediaType.TEXT_PLAIN) -@RequiredArgsConstructor -@Singleton -public class RobotsTxtDisallowAllServlet { - - private final CommaFeedConfiguration config; - - @GET - public Response get() { - if (config.hideFromWebCrawlers()) { - return Response.ok("User-agent: *\nDisallow: /").build(); - } else { - return Response.status(HttpStatus.SC_NOT_FOUND).build(); - } - } +package com.commafeed.frontend.servlet; + +import jakarta.annotation.security.PermitAll; +import jakarta.inject.Singleton; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import org.apache.hc.core5.http.HttpStatus; + +import com.commafeed.CommaFeedConfiguration; + +import lombok.RequiredArgsConstructor; + +@Path("/robots.txt") +@PermitAll +@Produces(MediaType.TEXT_PLAIN) +@RequiredArgsConstructor +@Singleton +public class RobotsTxtDisallowAllServlet { + + private final CommaFeedConfiguration config; + + @GET + public Response get() { + if (config.hideFromWebCrawlers()) { + return Response.ok("User-agent: *\nDisallow: /").build(); + } else { + return Response.status(HttpStatus.SC_NOT_FOUND).build(); + } + } } \ No newline at end of file diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/ws/WebSocketEndpoint.java b/commafeed-server/src/main/java/com/commafeed/frontend/ws/WebSocketEndpoint.java index 20e590d3..9e365ce1 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/ws/WebSocketEndpoint.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/ws/WebSocketEndpoint.java @@ -1,64 +1,64 @@ -package com.commafeed.frontend.ws; - -import java.io.IOException; - -import jakarta.inject.Singleton; -import jakarta.websocket.CloseReason; -import jakarta.websocket.CloseReason.CloseCodes; -import jakarta.websocket.OnClose; -import jakarta.websocket.OnMessage; -import jakarta.websocket.OnOpen; -import jakarta.websocket.Session; -import jakarta.websocket.server.ServerEndpoint; - -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.model.User; -import com.commafeed.security.AuthenticationContext; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Singleton -@ServerEndpoint("/ws") -@RequiredArgsConstructor -public class WebSocketEndpoint { - - private final AuthenticationContext authenticationContext; - private final CommaFeedConfiguration config; - private final WebSocketSessions sessions; - - @OnOpen - public void onOpen(Session session) { - User user = authenticationContext.getCurrentUser(); - if (user == null) { - reject(session); - return; - } - - log.debug("created websocket session for user '{}'", user.getName()); - sessions.add(user.getId(), session); - session.setMaxIdleTimeout(config.websocket().pingInterval().toMillis() + 10000); - } - - @OnMessage - public void onMessage(String message, Session session) { - if ("ping".equals(message)) { - session.getAsyncRemote().sendText("pong"); - } - } - - @OnClose - public void onClose(Session session) { - sessions.remove(session); - } - - private void reject(Session session) { - try { - session.close(new CloseReason(CloseCodes.VIOLATED_POLICY, "unauthorized")); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - -} +package com.commafeed.frontend.ws; + +import java.io.IOException; + +import jakarta.inject.Singleton; +import jakarta.websocket.CloseReason; +import jakarta.websocket.CloseReason.CloseCodes; +import jakarta.websocket.OnClose; +import jakarta.websocket.OnMessage; +import jakarta.websocket.OnOpen; +import jakarta.websocket.Session; +import jakarta.websocket.server.ServerEndpoint; + +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.backend.model.User; +import com.commafeed.security.AuthenticationContext; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Singleton +@ServerEndpoint("/ws") +@RequiredArgsConstructor +public class WebSocketEndpoint { + + private final AuthenticationContext authenticationContext; + private final CommaFeedConfiguration config; + private final WebSocketSessions sessions; + + @OnOpen + public void onOpen(Session session) { + User user = authenticationContext.getCurrentUser(); + if (user == null) { + reject(session); + return; + } + + log.debug("created websocket session for user '{}'", user.getName()); + sessions.add(user.getId(), session); + session.setMaxIdleTimeout(config.websocket().pingInterval().toMillis() + 10000); + } + + @OnMessage + public void onMessage(String message, Session session) { + if ("ping".equals(message)) { + session.getAsyncRemote().sendText("pong"); + } + } + + @OnClose + public void onClose(Session session) { + sessions.remove(session); + } + + private void reject(Session session) { + try { + session.close(new CloseReason(CloseCodes.VIOLATED_POLICY, "unauthorized")); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/ws/WebSocketMessageBuilder.java b/commafeed-server/src/main/java/com/commafeed/frontend/ws/WebSocketMessageBuilder.java index f0212533..8a7cbcdb 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/ws/WebSocketMessageBuilder.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/ws/WebSocketMessageBuilder.java @@ -1,14 +1,14 @@ -package com.commafeed.frontend.ws; - -import com.commafeed.backend.model.FeedSubscription; - -import lombok.experimental.UtilityClass; - -@UtilityClass -public class WebSocketMessageBuilder { - - public static String newFeedEntries(FeedSubscription subscription, long count) { - return String.format("%s:%s:%s", "new-feed-entries", subscription.getId(), count); - } - -} +package com.commafeed.frontend.ws; + +import com.commafeed.backend.model.FeedSubscription; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public class WebSocketMessageBuilder { + + public static String newFeedEntries(FeedSubscription subscription, long count) { + return String.format("%s:%s:%s", "new-feed-entries", subscription.getId(), count); + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/ws/WebSocketSessions.java b/commafeed-server/src/main/java/com/commafeed/frontend/ws/WebSocketSessions.java index 277cb9c8..0a7bdf14 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/ws/WebSocketSessions.java +++ b/commafeed-server/src/main/java/com/commafeed/frontend/ws/WebSocketSessions.java @@ -1,49 +1,49 @@ -package com.commafeed.frontend.ws; - -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; - -import jakarta.inject.Singleton; -import jakarta.websocket.Session; - -import com.codahale.metrics.Gauge; -import com.codahale.metrics.MetricRegistry; -import com.commafeed.backend.model.User; - -import lombok.extern.slf4j.Slf4j; - -@Singleton -@Slf4j -public class WebSocketSessions { - - // a user may have multiple sessions (two tabs, two devices, ...) - private final Map> sessions = new ConcurrentHashMap<>(); - - public WebSocketSessions(MetricRegistry metrics) { - metrics.register(MetricRegistry.name(getClass(), "users"), - (Gauge) () -> sessions.values().stream().filter(v -> !v.isEmpty()).count()); - metrics.register(MetricRegistry.name(getClass(), "sessions"), - (Gauge) () -> sessions.values().stream().mapToLong(Set::size).sum()); - } - - public void add(Long userId, Session session) { - sessions.computeIfAbsent(userId, v -> ConcurrentHashMap.newKeySet()).add(session); - } - - public void remove(Session session) { - sessions.values().forEach(v -> v.remove(session)); - } - - public void sendMessage(User user, String text) { - Set userSessions = sessions.get(user.getId()); - if (userSessions != null && !userSessions.isEmpty()) { - log.debug("sending '{}' to user {} via websocket ({} sessions)", text, user.getId(), userSessions.size()); - for (Session userSession : userSessions) { - if (userSession.isOpen()) { - userSession.getAsyncRemote().sendText(text); - } - } - } - } -} +package com.commafeed.frontend.ws; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import jakarta.inject.Singleton; +import jakarta.websocket.Session; + +import com.codahale.metrics.Gauge; +import com.codahale.metrics.MetricRegistry; +import com.commafeed.backend.model.User; + +import lombok.extern.slf4j.Slf4j; + +@Singleton +@Slf4j +public class WebSocketSessions { + + // a user may have multiple sessions (two tabs, two devices, ...) + private final Map> sessions = new ConcurrentHashMap<>(); + + public WebSocketSessions(MetricRegistry metrics) { + metrics.register(MetricRegistry.name(getClass(), "users"), + (Gauge) () -> sessions.values().stream().filter(v -> !v.isEmpty()).count()); + metrics.register(MetricRegistry.name(getClass(), "sessions"), + (Gauge) () -> sessions.values().stream().mapToLong(Set::size).sum()); + } + + public void add(Long userId, Session session) { + sessions.computeIfAbsent(userId, v -> ConcurrentHashMap.newKeySet()).add(session); + } + + public void remove(Session session) { + sessions.values().forEach(v -> v.remove(session)); + } + + public void sendMessage(User user, String text) { + Set userSessions = sessions.get(user.getId()); + if (userSessions != null && !userSessions.isEmpty()) { + log.debug("sending '{}' to user {} via websocket ({} sessions)", text, user.getId(), userSessions.size()); + for (Session userSession : userSessions) { + if (userSession.isOpen()) { + userSession.getAsyncRemote().sendText(text); + } + } + } + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/security/AuthenticationContext.java b/commafeed-server/src/main/java/com/commafeed/security/AuthenticationContext.java index 39af33a2..ca8e11e2 100644 --- a/commafeed-server/src/main/java/com/commafeed/security/AuthenticationContext.java +++ b/commafeed-server/src/main/java/com/commafeed/security/AuthenticationContext.java @@ -1,30 +1,30 @@ -package com.commafeed.security; - -import jakarta.inject.Singleton; - -import com.commafeed.backend.dao.UserDAO; -import com.commafeed.backend.model.User; - -import io.quarkus.security.identity.SecurityIdentity; -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Singleton -public class AuthenticationContext { - - private final SecurityIdentity securityIdentity; - private final UserDAO userDAO; - - public User getCurrentUser() { - if (securityIdentity.isAnonymous()) { - return null; - } - - String userId = securityIdentity.getPrincipal().getName(); - if (userId == null) { - return null; - } - - return userDAO.findById(Long.valueOf(userId)); - } -} +package com.commafeed.security; + +import jakarta.inject.Singleton; + +import com.commafeed.backend.dao.UserDAO; +import com.commafeed.backend.model.User; + +import io.quarkus.security.identity.SecurityIdentity; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Singleton +public class AuthenticationContext { + + private final SecurityIdentity securityIdentity; + private final UserDAO userDAO; + + public User getCurrentUser() { + if (securityIdentity.isAnonymous()) { + return null; + } + + String userId = securityIdentity.getPrincipal().getName(); + if (userId == null) { + return null; + } + + return userDAO.findById(Long.valueOf(userId)); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/security/Roles.java b/commafeed-server/src/main/java/com/commafeed/security/Roles.java index f614ca94..cfffe474 100644 --- a/commafeed-server/src/main/java/com/commafeed/security/Roles.java +++ b/commafeed-server/src/main/java/com/commafeed/security/Roles.java @@ -1,6 +1,6 @@ -package com.commafeed.security; - -public class Roles { - public static final String USER = "USER"; - public static final String ADMIN = "ADMIN"; -} +package com.commafeed.security; + +public class Roles { + public static final String USER = "USER"; + public static final String ADMIN = "ADMIN"; +} diff --git a/commafeed-server/src/main/java/com/commafeed/security/identity/DatabaseApiKeyIdentityProvider.java b/commafeed-server/src/main/java/com/commafeed/security/identity/DatabaseApiKeyIdentityProvider.java index 664b6658..4efd63dd 100644 --- a/commafeed-server/src/main/java/com/commafeed/security/identity/DatabaseApiKeyIdentityProvider.java +++ b/commafeed-server/src/main/java/com/commafeed/security/identity/DatabaseApiKeyIdentityProvider.java @@ -1,51 +1,51 @@ -package com.commafeed.security.identity; - -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -import jakarta.inject.Singleton; - -import com.commafeed.backend.dao.UnitOfWork; -import com.commafeed.backend.model.User; -import com.commafeed.backend.model.UserRole.Role; -import com.commafeed.backend.service.UserService; - -import io.quarkus.security.AuthenticationFailedException; -import io.quarkus.security.identity.AuthenticationRequestContext; -import io.quarkus.security.identity.IdentityProvider; -import io.quarkus.security.identity.SecurityIdentity; -import io.quarkus.security.identity.request.TokenAuthenticationRequest; -import io.quarkus.security.runtime.QuarkusPrincipal; -import io.quarkus.security.runtime.QuarkusSecurityIdentity; -import io.smallrye.mutiny.Uni; -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Singleton -public class DatabaseApiKeyIdentityProvider implements IdentityProvider { - - private final UnitOfWork unitOfWork; - private final UserService userService; - - @Override - public Class getRequestType() { - return TokenAuthenticationRequest.class; - } - - @Override - public Uni authenticate(TokenAuthenticationRequest request, AuthenticationRequestContext context) { - return context.runBlocking(() -> { - Optional user = unitOfWork.call(() -> userService.login(request.getToken().getToken())); - if (user.isEmpty()) { - throw new AuthenticationFailedException("could not find a user with this api key"); - } - - Set roles = unitOfWork.call(() -> userService.getRoles(user.get())); - return QuarkusSecurityIdentity.builder() - .setPrincipal(new QuarkusPrincipal(String.valueOf(user.get().getId()))) - .addRoles(roles.stream().map(Enum::name).collect(Collectors.toSet())) - .build(); - }); - } -} +package com.commafeed.security.identity; + +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import jakarta.inject.Singleton; + +import com.commafeed.backend.dao.UnitOfWork; +import com.commafeed.backend.model.User; +import com.commafeed.backend.model.UserRole.Role; +import com.commafeed.backend.service.UserService; + +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.IdentityProvider; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.TokenAuthenticationRequest; +import io.quarkus.security.runtime.QuarkusPrincipal; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.smallrye.mutiny.Uni; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Singleton +public class DatabaseApiKeyIdentityProvider implements IdentityProvider { + + private final UnitOfWork unitOfWork; + private final UserService userService; + + @Override + public Class getRequestType() { + return TokenAuthenticationRequest.class; + } + + @Override + public Uni authenticate(TokenAuthenticationRequest request, AuthenticationRequestContext context) { + return context.runBlocking(() -> { + Optional user = unitOfWork.call(() -> userService.login(request.getToken().getToken())); + if (user.isEmpty()) { + throw new AuthenticationFailedException("could not find a user with this api key"); + } + + Set roles = unitOfWork.call(() -> userService.getRoles(user.get())); + return QuarkusSecurityIdentity.builder() + .setPrincipal(new QuarkusPrincipal(String.valueOf(user.get().getId()))) + .addRoles(roles.stream().map(Enum::name).collect(Collectors.toSet())) + .build(); + }); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/security/identity/DatabaseUsernamePasswordIdentityProvider.java b/commafeed-server/src/main/java/com/commafeed/security/identity/DatabaseUsernamePasswordIdentityProvider.java index 3525b816..2fd42183 100644 --- a/commafeed-server/src/main/java/com/commafeed/security/identity/DatabaseUsernamePasswordIdentityProvider.java +++ b/commafeed-server/src/main/java/com/commafeed/security/identity/DatabaseUsernamePasswordIdentityProvider.java @@ -1,52 +1,52 @@ -package com.commafeed.security.identity; - -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -import jakarta.inject.Singleton; - -import com.commafeed.backend.dao.UnitOfWork; -import com.commafeed.backend.model.User; -import com.commafeed.backend.model.UserRole.Role; -import com.commafeed.backend.service.UserService; - -import io.quarkus.security.AuthenticationFailedException; -import io.quarkus.security.identity.AuthenticationRequestContext; -import io.quarkus.security.identity.IdentityProvider; -import io.quarkus.security.identity.SecurityIdentity; -import io.quarkus.security.identity.request.UsernamePasswordAuthenticationRequest; -import io.quarkus.security.runtime.QuarkusPrincipal; -import io.quarkus.security.runtime.QuarkusSecurityIdentity; -import io.smallrye.mutiny.Uni; -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Singleton -public class DatabaseUsernamePasswordIdentityProvider implements IdentityProvider { - - private final UnitOfWork unitOfWork; - private final UserService userService; - - @Override - public Class getRequestType() { - return UsernamePasswordAuthenticationRequest.class; - } - - @Override - public Uni authenticate(UsernamePasswordAuthenticationRequest request, AuthenticationRequestContext context) { - return context.runBlocking(() -> { - Optional user = unitOfWork - .call(() -> userService.login(request.getUsername(), new String(request.getPassword().getPassword()))); - if (user.isEmpty()) { - throw new AuthenticationFailedException("wrong username or password"); - } - - Set roles = unitOfWork.call(() -> userService.getRoles(user.get())); - return QuarkusSecurityIdentity.builder() - .setPrincipal(new QuarkusPrincipal(String.valueOf(user.get().getId()))) - .addRoles(roles.stream().map(Enum::name).collect(Collectors.toSet())) - .build(); - }); - } -} +package com.commafeed.security.identity; + +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import jakarta.inject.Singleton; + +import com.commafeed.backend.dao.UnitOfWork; +import com.commafeed.backend.model.User; +import com.commafeed.backend.model.UserRole.Role; +import com.commafeed.backend.service.UserService; + +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.IdentityProvider; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.UsernamePasswordAuthenticationRequest; +import io.quarkus.security.runtime.QuarkusPrincipal; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.smallrye.mutiny.Uni; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Singleton +public class DatabaseUsernamePasswordIdentityProvider implements IdentityProvider { + + private final UnitOfWork unitOfWork; + private final UserService userService; + + @Override + public Class getRequestType() { + return UsernamePasswordAuthenticationRequest.class; + } + + @Override + public Uni authenticate(UsernamePasswordAuthenticationRequest request, AuthenticationRequestContext context) { + return context.runBlocking(() -> { + Optional user = unitOfWork + .call(() -> userService.login(request.getUsername(), new String(request.getPassword().getPassword()))); + if (user.isEmpty()) { + throw new AuthenticationFailedException("wrong username or password"); + } + + Set roles = unitOfWork.call(() -> userService.getRoles(user.get())); + return QuarkusSecurityIdentity.builder() + .setPrincipal(new QuarkusPrincipal(String.valueOf(user.get().getId()))) + .addRoles(roles.stream().map(Enum::name).collect(Collectors.toSet())) + .build(); + }); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/security/identity/TrustedIdentityProvider.java b/commafeed-server/src/main/java/com/commafeed/security/identity/TrustedIdentityProvider.java index 77d5eb9a..6de26dc5 100644 --- a/commafeed-server/src/main/java/com/commafeed/security/identity/TrustedIdentityProvider.java +++ b/commafeed-server/src/main/java/com/commafeed/security/identity/TrustedIdentityProvider.java @@ -1,58 +1,58 @@ -package com.commafeed.security.identity; - -import java.util.Set; -import java.util.stream.Collectors; - -import jakarta.inject.Singleton; - -import com.commafeed.backend.dao.UnitOfWork; -import com.commafeed.backend.dao.UserDAO; -import com.commafeed.backend.model.User; -import com.commafeed.backend.model.UserRole.Role; -import com.commafeed.backend.service.UserService; -import com.commafeed.backend.service.internal.PostLoginActivities; - -import io.quarkus.security.AuthenticationFailedException; -import io.quarkus.security.identity.AuthenticationRequestContext; -import io.quarkus.security.identity.IdentityProvider; -import io.quarkus.security.identity.SecurityIdentity; -import io.quarkus.security.identity.request.TrustedAuthenticationRequest; -import io.quarkus.security.runtime.QuarkusPrincipal; -import io.quarkus.security.runtime.QuarkusSecurityIdentity; -import io.smallrye.mutiny.Uni; -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Singleton -public class TrustedIdentityProvider implements IdentityProvider { - - private final UnitOfWork unitOfWork; - private final UserService userService; - private final UserDAO userDAO; - private final PostLoginActivities postLoginActivities; - - @Override - public Class getRequestType() { - return TrustedAuthenticationRequest.class; - } - - @Override - public Uni authenticate(TrustedAuthenticationRequest request, AuthenticationRequestContext context) { - return context.runBlocking(() -> { - Long userId = Long.valueOf(request.getPrincipal()); - User user = unitOfWork.call(() -> userDAO.findById(userId)); - if (user == null) { - throw new AuthenticationFailedException("user not found"); - } - - // execute post login activities manually because we didn't call login() since we received a trusted authentication request - unitOfWork.run(() -> postLoginActivities.executeFor(user)); - - Set roles = unitOfWork.call(() -> userService.getRoles(user)); - return QuarkusSecurityIdentity.builder() - .setPrincipal(new QuarkusPrincipal(String.valueOf(userId))) - .addRoles(roles.stream().map(Enum::name).collect(Collectors.toSet())) - .build(); - }); - } -} +package com.commafeed.security.identity; + +import java.util.Set; +import java.util.stream.Collectors; + +import jakarta.inject.Singleton; + +import com.commafeed.backend.dao.UnitOfWork; +import com.commafeed.backend.dao.UserDAO; +import com.commafeed.backend.model.User; +import com.commafeed.backend.model.UserRole.Role; +import com.commafeed.backend.service.UserService; +import com.commafeed.backend.service.internal.PostLoginActivities; + +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.IdentityProvider; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.TrustedAuthenticationRequest; +import io.quarkus.security.runtime.QuarkusPrincipal; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.smallrye.mutiny.Uni; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Singleton +public class TrustedIdentityProvider implements IdentityProvider { + + private final UnitOfWork unitOfWork; + private final UserService userService; + private final UserDAO userDAO; + private final PostLoginActivities postLoginActivities; + + @Override + public Class getRequestType() { + return TrustedAuthenticationRequest.class; + } + + @Override + public Uni authenticate(TrustedAuthenticationRequest request, AuthenticationRequestContext context) { + return context.runBlocking(() -> { + Long userId = Long.valueOf(request.getPrincipal()); + User user = unitOfWork.call(() -> userDAO.findById(userId)); + if (user == null) { + throw new AuthenticationFailedException("user not found"); + } + + // execute post login activities manually because we didn't call login() since we received a trusted authentication request + unitOfWork.run(() -> postLoginActivities.executeFor(user)); + + Set roles = unitOfWork.call(() -> userService.getRoles(user)); + return QuarkusSecurityIdentity.builder() + .setPrincipal(new QuarkusPrincipal(String.valueOf(userId))) + .addRoles(roles.stream().map(Enum::name).collect(Collectors.toSet())) + .build(); + }); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/security/mechanism/ApiKeyAuthenticationMecanism.java b/commafeed-server/src/main/java/com/commafeed/security/mechanism/ApiKeyAuthenticationMecanism.java index 44cf6159..be6e3bd7 100644 --- a/commafeed-server/src/main/java/com/commafeed/security/mechanism/ApiKeyAuthenticationMecanism.java +++ b/commafeed-server/src/main/java/com/commafeed/security/mechanism/ApiKeyAuthenticationMecanism.java @@ -1,47 +1,47 @@ -package com.commafeed.security.mechanism; - -import java.util.Optional; -import java.util.Set; - -import jakarta.inject.Singleton; - -import io.quarkus.security.credential.TokenCredential; -import io.quarkus.security.identity.IdentityProviderManager; -import io.quarkus.security.identity.SecurityIdentity; -import io.quarkus.security.identity.request.AuthenticationRequest; -import io.quarkus.security.identity.request.TokenAuthenticationRequest; -import io.quarkus.vertx.http.runtime.security.ChallengeData; -import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; -import io.smallrye.mutiny.Uni; -import io.vertx.ext.web.RoutingContext; - -@Singleton -public class ApiKeyAuthenticationMecanism implements HttpAuthenticationMechanism { - - @Override - public Uni authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) { - // only authorize api key for GET requests - if (!context.request().method().name().equals("GET")) { - return Uni.createFrom().optional(Optional.empty()); - } - - String apiKey = context.request().getParam("apiKey"); - if (apiKey == null) { - return Uni.createFrom().optional(Optional.empty()); - } - - TokenCredential token = new TokenCredential(apiKey, "apiKey"); - TokenAuthenticationRequest request = new TokenAuthenticationRequest(token); - return identityProviderManager.authenticate(request); - } - - @Override - public Uni getChallenge(RoutingContext context) { - return Uni.createFrom().optional(Optional.empty()); - } - - @Override - public Set> getCredentialTypes() { - return Set.of(TokenAuthenticationRequest.class); - } -} +package com.commafeed.security.mechanism; + +import java.util.Optional; +import java.util.Set; + +import jakarta.inject.Singleton; + +import io.quarkus.security.credential.TokenCredential; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.AuthenticationRequest; +import io.quarkus.security.identity.request.TokenAuthenticationRequest; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +@Singleton +public class ApiKeyAuthenticationMecanism implements HttpAuthenticationMechanism { + + @Override + public Uni authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) { + // only authorize api key for GET requests + if (!context.request().method().name().equals("GET")) { + return Uni.createFrom().optional(Optional.empty()); + } + + String apiKey = context.request().getParam("apiKey"); + if (apiKey == null) { + return Uni.createFrom().optional(Optional.empty()); + } + + TokenCredential token = new TokenCredential(apiKey, "apiKey"); + TokenAuthenticationRequest request = new TokenAuthenticationRequest(token); + return identityProviderManager.authenticate(request); + } + + @Override + public Uni getChallenge(RoutingContext context) { + return Uni.createFrom().optional(Optional.empty()); + } + + @Override + public Set> getCredentialTypes() { + return Set.of(TokenAuthenticationRequest.class); + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/security/password/PasswordConstraintValidator.java b/commafeed-server/src/main/java/com/commafeed/security/password/PasswordConstraintValidator.java index bcc1b129..8045d0ed 100644 --- a/commafeed-server/src/main/java/com/commafeed/security/password/PasswordConstraintValidator.java +++ b/commafeed-server/src/main/java/com/commafeed/security/password/PasswordConstraintValidator.java @@ -1,72 +1,72 @@ -package com.commafeed.security.password; - -import java.util.List; - -import jakarta.validation.ConstraintValidator; -import jakarta.validation.ConstraintValidatorContext; - -import org.apache.commons.lang3.StringUtils; -import org.passay.CharacterRule; -import org.passay.EnglishCharacterData; -import org.passay.LengthRule; -import org.passay.PasswordData; -import org.passay.PasswordValidator; -import org.passay.RuleResult; -import org.passay.WhitespaceRule; - -import lombok.Setter; - -public class PasswordConstraintValidator implements ConstraintValidator { - - @Setter - private static boolean strict = true; - - @Override - public void initialize(ValidPassword constraintAnnotation) { - // nothing to do - } - - @Override - public boolean isValid(String value, ConstraintValidatorContext context) { - if (StringUtils.isBlank(value)) { - return true; - } - - PasswordValidator validator = strict ? buildStrictPasswordValidator() : buildLoosePasswordValidator(); - RuleResult result = validator.validate(new PasswordData(value)); - - if (result.isValid()) { - return true; - } - - List messages = validator.getMessages(result); - String message = String.join(System.lineSeparator(), messages); - context.buildConstraintViolationWithTemplate(message).addConstraintViolation().disableDefaultConstraintViolation(); - return false; - } - - private PasswordValidator buildStrictPasswordValidator() { - return new PasswordValidator( - // length - new LengthRule(8, 256), - // 1 uppercase char - new CharacterRule(EnglishCharacterData.UpperCase, 1), - // 1 lowercase char - new CharacterRule(EnglishCharacterData.LowerCase, 1), - // 1 digit - new CharacterRule(EnglishCharacterData.Digit, 1), - // 1 special char - new CharacterRule(EnglishCharacterData.Special, 1), - // no whitespace - new WhitespaceRule()); - } - - private PasswordValidator buildLoosePasswordValidator() { - return new PasswordValidator( - // length - new LengthRule(6, 256), - // no whitespace - new WhitespaceRule()); - } - -} +package com.commafeed.security.password; + +import java.util.List; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import org.apache.commons.lang3.StringUtils; +import org.passay.CharacterRule; +import org.passay.EnglishCharacterData; +import org.passay.LengthRule; +import org.passay.PasswordData; +import org.passay.PasswordValidator; +import org.passay.RuleResult; +import org.passay.WhitespaceRule; + +import lombok.Setter; + +public class PasswordConstraintValidator implements ConstraintValidator { + + @Setter + private static boolean strict = true; + + @Override + public void initialize(ValidPassword constraintAnnotation) { + // nothing to do + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (StringUtils.isBlank(value)) { + return true; + } + + PasswordValidator validator = strict ? buildStrictPasswordValidator() : buildLoosePasswordValidator(); + RuleResult result = validator.validate(new PasswordData(value)); + + if (result.isValid()) { + return true; + } + + List messages = validator.getMessages(result); + String message = String.join(System.lineSeparator(), messages); + context.buildConstraintViolationWithTemplate(message).addConstraintViolation().disableDefaultConstraintViolation(); + return false; + } + + private PasswordValidator buildStrictPasswordValidator() { + return new PasswordValidator( + // length + new LengthRule(8, 256), + // 1 uppercase char + new CharacterRule(EnglishCharacterData.UpperCase, 1), + // 1 lowercase char + new CharacterRule(EnglishCharacterData.LowerCase, 1), + // 1 digit + new CharacterRule(EnglishCharacterData.Digit, 1), + // 1 special char + new CharacterRule(EnglishCharacterData.Special, 1), + // no whitespace + new WhitespaceRule()); + } + + private PasswordValidator buildLoosePasswordValidator() { + return new PasswordValidator( + // length + new LengthRule(6, 256), + // no whitespace + new WhitespaceRule()); + } + +} diff --git a/commafeed-server/src/main/java/com/commafeed/security/password/ValidPassword.java b/commafeed-server/src/main/java/com/commafeed/security/password/ValidPassword.java index fc559c50..e563e0d4 100644 --- a/commafeed-server/src/main/java/com/commafeed/security/password/ValidPassword.java +++ b/commafeed-server/src/main/java/com/commafeed/security/password/ValidPassword.java @@ -1,23 +1,23 @@ -package com.commafeed.security.password; - -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -import jakarta.validation.Constraint; -import jakarta.validation.Payload; - -@Documented -@Constraint(validatedBy = PasswordConstraintValidator.class) -@Target({ ElementType.TYPE, ElementType.FIELD, ElementType.ANNOTATION_TYPE }) -@Retention(RetentionPolicy.RUNTIME) -public @interface ValidPassword { - - String message() default "Invalid Password"; - - Class[] groups() default {}; - - Class[] payload() default {}; -} +package com.commafeed.security.password; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Documented +@Constraint(validatedBy = PasswordConstraintValidator.class) +@Target({ ElementType.TYPE, ElementType.FIELD, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidPassword { + + String message() default "Invalid Password"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/commafeed-server/src/main/java/com/commafeed/tools/CommaFeedPropertiesGenerator.java b/commafeed-server/src/main/java/com/commafeed/tools/CommaFeedPropertiesGenerator.java index 114498cd..c3d540c3 100644 --- a/commafeed-server/src/main/java/com/commafeed/tools/CommaFeedPropertiesGenerator.java +++ b/commafeed-server/src/main/java/com/commafeed/tools/CommaFeedPropertiesGenerator.java @@ -1,80 +1,80 @@ -package com.commafeed.tools; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - -import com.commafeed.CommaFeedConfiguration; - -import io.quarkus.annotation.processor.Outputs; -import io.quarkus.annotation.processor.documentation.config.model.AbstractConfigItem; -import io.quarkus.annotation.processor.documentation.config.model.ConfigProperty; -import io.quarkus.annotation.processor.documentation.config.model.ConfigRoot; -import io.quarkus.annotation.processor.documentation.config.model.ConfigSection; -import io.quarkus.annotation.processor.documentation.config.model.JavadocElements; -import io.quarkus.annotation.processor.documentation.config.model.ResolvedModel; -import io.quarkus.annotation.processor.documentation.config.util.JacksonMappers; - -/** - * This class generates an application.properties file with all the properties from {@link CommaFeedConfiguration}. - * - * This is useful for people who want to be able to configure CommaFeed without having to look at the code or the documentation, or for - * distribution packages that want to provide a default configuration file. - * - **/ -public class CommaFeedPropertiesGenerator { - - private final List lines = new ArrayList<>(); - - public static void main(String[] args) throws Exception { - new CommaFeedPropertiesGenerator().generate(args); - } - - private void generate(String[] args) throws IOException { - Path targetPath = Paths.get(args[0]); - - ResolvedModel resolvedModel = JacksonMappers.yamlObjectReader() - .readValue(targetPath.resolve(Outputs.QUARKUS_CONFIG_DOC_MODEL).toFile(), ResolvedModel.class); - JavadocElements javadocElements = JacksonMappers.yamlObjectReader() - .readValue(targetPath.resolve(Outputs.QUARKUS_CONFIG_DOC_JAVADOC).toFile(), JavadocElements.class); - - for (ConfigRoot configRoot : resolvedModel.getConfigRoots()) { - for (AbstractConfigItem item : configRoot.getItems()) { - handleAbstractConfigItem(item, javadocElements); - } - } - - Files.writeString(targetPath.resolve("quarkus-generated-doc").resolve("application.properties"), String.join("\n", lines)); - } - - private void handleAbstractConfigItem(AbstractConfigItem item, JavadocElements javadocElements) { - if (item.isSection()) { - handleSection((ConfigSection) item, javadocElements); - } else { - handleProperty((ConfigProperty) item, javadocElements); - } - } - - private void handleSection(ConfigSection section, JavadocElements javadocElements) { - for (AbstractConfigItem item : section.getItems()) { - handleAbstractConfigItem(item, javadocElements); - } - } - - private void handleProperty(ConfigProperty property, JavadocElements javadocElements) { - String key = property.getPath().property(); - String description = javadocElements.elements() - .get(property.getSourceType() + "." + property.getSourceElementName()) - .description() - .replace("\n", "\n# "); - String defaultValue = Optional.ofNullable(property.getDefaultValue()).orElse("").toLowerCase(); - - lines.add("# " + description); - lines.add(key + "=" + defaultValue); - lines.add(""); - } -} +package com.commafeed.tools; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import com.commafeed.CommaFeedConfiguration; + +import io.quarkus.annotation.processor.Outputs; +import io.quarkus.annotation.processor.documentation.config.model.AbstractConfigItem; +import io.quarkus.annotation.processor.documentation.config.model.ConfigProperty; +import io.quarkus.annotation.processor.documentation.config.model.ConfigRoot; +import io.quarkus.annotation.processor.documentation.config.model.ConfigSection; +import io.quarkus.annotation.processor.documentation.config.model.JavadocElements; +import io.quarkus.annotation.processor.documentation.config.model.ResolvedModel; +import io.quarkus.annotation.processor.documentation.config.util.JacksonMappers; + +/** + * This class generates an application.properties file with all the properties from {@link CommaFeedConfiguration}. + * + * This is useful for people who want to be able to configure CommaFeed without having to look at the code or the documentation, or for + * distribution packages that want to provide a default configuration file. + * + **/ +public class CommaFeedPropertiesGenerator { + + private final List lines = new ArrayList<>(); + + public static void main(String[] args) throws Exception { + new CommaFeedPropertiesGenerator().generate(args); + } + + private void generate(String[] args) throws IOException { + Path targetPath = Paths.get(args[0]); + + ResolvedModel resolvedModel = JacksonMappers.yamlObjectReader() + .readValue(targetPath.resolve(Outputs.QUARKUS_CONFIG_DOC_MODEL).toFile(), ResolvedModel.class); + JavadocElements javadocElements = JacksonMappers.yamlObjectReader() + .readValue(targetPath.resolve(Outputs.QUARKUS_CONFIG_DOC_JAVADOC).toFile(), JavadocElements.class); + + for (ConfigRoot configRoot : resolvedModel.getConfigRoots()) { + for (AbstractConfigItem item : configRoot.getItems()) { + handleAbstractConfigItem(item, javadocElements); + } + } + + Files.writeString(targetPath.resolve("quarkus-generated-doc").resolve("application.properties"), String.join("\n", lines)); + } + + private void handleAbstractConfigItem(AbstractConfigItem item, JavadocElements javadocElements) { + if (item.isSection()) { + handleSection((ConfigSection) item, javadocElements); + } else { + handleProperty((ConfigProperty) item, javadocElements); + } + } + + private void handleSection(ConfigSection section, JavadocElements javadocElements) { + for (AbstractConfigItem item : section.getItems()) { + handleAbstractConfigItem(item, javadocElements); + } + } + + private void handleProperty(ConfigProperty property, JavadocElements javadocElements) { + String key = property.getPath().property(); + String description = javadocElements.elements() + .get(property.getSourceType() + "." + property.getSourceElementName()) + .description() + .replace("\n", "\n# "); + String defaultValue = Optional.ofNullable(property.getDefaultValue()).orElse("").toLowerCase(); + + lines.add("# " + description); + lines.add(key + "=" + defaultValue); + lines.add(""); + } +} diff --git a/commafeed-server/src/main/resources/META-INF/native-image/commafeed/resource-config.json b/commafeed-server/src/main/resources/META-INF/native-image/commafeed/resource-config.json index a8a50b21..7aacbaa5 100644 --- a/commafeed-server/src/main/resources/META-INF/native-image/commafeed/resource-config.json +++ b/commafeed-server/src/main/resources/META-INF/native-image/commafeed/resource-config.json @@ -1,10 +1,10 @@ -{ - "resources": { - "includes": [ - { "pattern": "^default_banner\\.txt$" }, - { "pattern": "^images/default_favicon\\.gif$" }, - { "pattern": "^git\\.properties$" }, - { "pattern": "^rome\\.properties$" } - ] - } -} +{ + "resources": { + "includes": [ + { "pattern": "^default_banner\\.txt$" }, + { "pattern": "^images/default_favicon\\.gif$" }, + { "pattern": "^git\\.properties$" }, + { "pattern": "^rome\\.properties$" } + ] + } +} diff --git a/commafeed-server/src/main/resources/META-INF/native-image/cssparser/resource-config.json b/commafeed-server/src/main/resources/META-INF/native-image/cssparser/resource-config.json index 29a9854d..667aa7fe 100644 --- a/commafeed-server/src/main/resources/META-INF/native-image/cssparser/resource-config.json +++ b/commafeed-server/src/main/resources/META-INF/native-image/cssparser/resource-config.json @@ -1,7 +1,7 @@ -{ - "bundles": [ - { - "name": "com.steadystate.css.parser.SACParserMessages" - } - ] +{ + "bundles": [ + { + "name": "com.steadystate.css.parser.SACParserMessages" + } + ] } \ No newline at end of file diff --git a/commafeed-server/src/main/resources/application.properties b/commafeed-server/src/main/resources/application.properties index c650c424..da83adab 100644 --- a/commafeed-server/src/main/resources/application.properties +++ b/commafeed-server/src/main/resources/application.properties @@ -1,63 +1,63 @@ -# http -quarkus.http.port=8082 -quarkus.http.test-port=8085 -quarkus.http.enable-compression=true - -# static files -## make sure the webapp is always up to date -quarkus.http.filter.index-html.header."Cache-Control"=no-cache -quarkus.http.filter.index-html.matches=/ -## make sure the openapi documentation is always up to date -quarkus.http.filter.openapi.header."Cache-Control"=no-cache -quarkus.http.filter.openapi.matches=/openapi[.](json|yaml) - -# security -quarkus.http.auth.basic=true -quarkus.http.auth.form.enabled=true -quarkus.http.auth.form.http-only-cookie=true -quarkus.http.auth.form.timeout=P30d -quarkus.http.auth.form.cookie-max-age=P30d -quarkus.http.auth.form.landing-page= -quarkus.http.auth.form.login-page= -quarkus.http.auth.form.error-page= - -# websocket -quarkus.websocket.dispatch-to-worker=true - -# database -quarkus.liquibase.change-log=migrations.xml -quarkus.liquibase.migrate-at-start=true - -# shutdown -quarkus.shutdown.timeout=5s - -# native -quarkus.native.march=compatibility -quarkus.native.add-all-charsets=true - - -# dev profile overrides -%dev.quarkus.http.port=8083 -%dev.quarkus.http.auth.session.encryption-key=123456789012345678901234567890 -%dev.quarkus.log.category."com.commafeed".level=DEBUG -# %dev.quarkus.hibernate-orm.log.sql=true -%dev.commafeed.users.create-demo-account=true - - -# test profile overrides -%test.quarkus.log.category."org.mockserver".level=WARN -%test.quarkus.log.category."liquibase".level=WARN -%test.commafeed.users.create-demo-account=true -%test.commafeed.users.allow-registrations=true -%test.commafeed.password-recovery-enabled=true -%test.commafeed.http-client.cache.enabled=false -%test.commafeed.http-client.block-local-addresses=false -%test.commafeed.database.cleanup.entries-max-age=0 -%test.commafeed.feed-refresh.force-refresh-cooldown-duration=1m - - -# prod profile overrides -%prod.quarkus.datasource.jdbc.url=jdbc:h2:./data/db;DEFRAG_ALWAYS=TRUE -%prod.quarkus.datasource.username=sa -%prod.quarkus.datasource.password=sa -%prod.quarkus.log.category."com.rometools.modules".level=ERROR +# http +quarkus.http.port=8082 +quarkus.http.test-port=8085 +quarkus.http.enable-compression=true + +# static files +## make sure the webapp is always up to date +quarkus.http.filter.index-html.header."Cache-Control"=no-cache +quarkus.http.filter.index-html.matches=/ +## make sure the openapi documentation is always up to date +quarkus.http.filter.openapi.header."Cache-Control"=no-cache +quarkus.http.filter.openapi.matches=/openapi[.](json|yaml) + +# security +quarkus.http.auth.basic=true +quarkus.http.auth.form.enabled=true +quarkus.http.auth.form.http-only-cookie=true +quarkus.http.auth.form.timeout=P30d +quarkus.http.auth.form.cookie-max-age=P30d +quarkus.http.auth.form.landing-page= +quarkus.http.auth.form.login-page= +quarkus.http.auth.form.error-page= + +# websocket +quarkus.websocket.dispatch-to-worker=true + +# database +quarkus.liquibase.change-log=migrations.xml +quarkus.liquibase.migrate-at-start=true + +# shutdown +quarkus.shutdown.timeout=5s + +# native +quarkus.native.march=compatibility +quarkus.native.add-all-charsets=true + + +# dev profile overrides +%dev.quarkus.http.port=8083 +%dev.quarkus.http.auth.session.encryption-key=123456789012345678901234567890 +%dev.quarkus.log.category."com.commafeed".level=DEBUG +# %dev.quarkus.hibernate-orm.log.sql=true +%dev.commafeed.users.create-demo-account=true + + +# test profile overrides +%test.quarkus.log.category."org.mockserver".level=WARN +%test.quarkus.log.category."liquibase".level=WARN +%test.commafeed.users.create-demo-account=true +%test.commafeed.users.allow-registrations=true +%test.commafeed.password-recovery-enabled=true +%test.commafeed.http-client.cache.enabled=false +%test.commafeed.http-client.block-local-addresses=false +%test.commafeed.database.cleanup.entries-max-age=0 +%test.commafeed.feed-refresh.force-refresh-cooldown-duration=1m + + +# prod profile overrides +%prod.quarkus.datasource.jdbc.url=jdbc:h2:./data/db;DEFRAG_ALWAYS=TRUE +%prod.quarkus.datasource.username=sa +%prod.quarkus.datasource.password=sa +%prod.quarkus.log.category."com.rometools.modules".level=ERROR diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-1.0.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-1.0.xml index b1713439..14fe3bbc 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-1.0.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-1.0.xml @@ -1,494 +1,494 @@ - - - - - 7:6d3ad493d25dd9c50067e804efc9ffcc - 7:896a68c1651397288c40f717ce0397b4 - 8:1b0879c4739d483c3b1d779e08fe770b - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 7:eccd6b37116ab35ee963aa46152e1ae5 - 7:ac622ab04aec79a7e5854d25511abaef - - - - - - - - - - - - - - - - - - - - 7:93155e15f0feabe936e1de35711bf85b - 7:c52f258e54d34156208cbfd2d8547fbd - 8:c59e763e4cc7d59ae58d843937cf4e77 - - - - - - - - - - - - - - - - - - - - - - - - - - - - 7:2d9e82da5573ac551df31a13f3bc40e5 - 7:c3cc179801e812635b53849301a1a1d1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 7:a2d83b0f7d1bf97a7553e94dd6100edf - 7:1c45f6b6a6e7583dd4c090a4a3930758 - - - - - - - - - - - - - - - - - - - - - 7:a9cf194a01c16b937a897aea934f09ae - 7:6a386e0b08e98bdba9ce55e26ab90eba - 8:ccfbff3df6f16c8686229c2da514e8b1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 7:e3a44d2e0f774dcb4efe36702c8d5f3f - 7:604b2bb0b62b7f0529e50e63c2b2cf0c - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 7:36e92eac052c7d2ce0ef75e3ec2cdf8d - 7:248affcafffd2243f8b0d16750e17af0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - 7:6d68765b2116ba88680d69c03b3cefd2 - 7:6112f92b437b4d0ecfcdf038fd04ed2f - - - - - - - - - - - - - - - - 7:eefc98cfa1b9bbf51fa6acd7a0d49c1b - 7:abbff58b88c8cebfb4548d17730a262d - - - - - - - - - - - - - - - - - - - - - - - 7:750e0990a8edebd0252df7d4adc7aa7c - 8:dd1676f356c3c70822d69d5103947948 - 8:ca2d6edef0263a78cab9cc0942972d50 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 7:985d6607a4350e032ea345d9a2f2f0c0 - 7:722eaff49d04d43c5b26da0929d3f707 - 8:451004f3bc72abac6a38d813881d3a87 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 7:6b97555d8c1a170b1c07d088e068ec95 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + 7:6d3ad493d25dd9c50067e804efc9ffcc + 7:896a68c1651397288c40f717ce0397b4 + 8:1b0879c4739d483c3b1d779e08fe770b + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 7:eccd6b37116ab35ee963aa46152e1ae5 + 7:ac622ab04aec79a7e5854d25511abaef + + + + + + + + + + + + + + + + + + + + 7:93155e15f0feabe936e1de35711bf85b + 7:c52f258e54d34156208cbfd2d8547fbd + 8:c59e763e4cc7d59ae58d843937cf4e77 + + + + + + + + + + + + + + + + + + + + + + + + + + + + 7:2d9e82da5573ac551df31a13f3bc40e5 + 7:c3cc179801e812635b53849301a1a1d1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 7:a2d83b0f7d1bf97a7553e94dd6100edf + 7:1c45f6b6a6e7583dd4c090a4a3930758 + + + + + + + + + + + + + + + + + + + + + 7:a9cf194a01c16b937a897aea934f09ae + 7:6a386e0b08e98bdba9ce55e26ab90eba + 8:ccfbff3df6f16c8686229c2da514e8b1 + + + + + + + + + + + + + + + + + + + + + + + + + + + 7:e3a44d2e0f774dcb4efe36702c8d5f3f + 7:604b2bb0b62b7f0529e50e63c2b2cf0c + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 7:36e92eac052c7d2ce0ef75e3ec2cdf8d + 7:248affcafffd2243f8b0d16750e17af0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + 7:6d68765b2116ba88680d69c03b3cefd2 + 7:6112f92b437b4d0ecfcdf038fd04ed2f + + + + + + + + + + + + + + + + 7:eefc98cfa1b9bbf51fa6acd7a0d49c1b + 7:abbff58b88c8cebfb4548d17730a262d + + + + + + + + + + + + + + + + + + + + + + + 7:750e0990a8edebd0252df7d4adc7aa7c + 8:dd1676f356c3c70822d69d5103947948 + 8:ca2d6edef0263a78cab9cc0942972d50 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 7:985d6607a4350e032ea345d9a2f2f0c0 + 7:722eaff49d04d43c5b26da0929d3f707 + 8:451004f3bc72abac6a38d813881d3a87 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 7:6b97555d8c1a170b1c07d088e068ec95 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-1.1.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-1.1.xml index 094de039..0d5d88a9 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-1.1.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-1.1.xml @@ -1,368 +1,368 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 7:094e881ed7e4fa14fb1f61147ce44acc - 7:f4bf2004a72bb41daa128df5771af902 - - - - - - - - - - insert into hibernate_sequences(sequence_name, sequence_next_hi_value) select 'APPLICATIONSETTINGS', COALESCE(ceiling(max(id) / 1000 + 2), 1) from APPLICATIONSETTINGS - insert into hibernate_sequences(sequence_name, sequence_next_hi_value) select 'FEEDCATEGORIES', COALESCE(ceiling(max(id) / 1000 + 2), 1) from FEEDCATEGORIES - insert into hibernate_sequences(sequence_name, sequence_next_hi_value) select 'FEEDENTRIES', COALESCE(ceiling(max(id) / 1000 + 2), 1) from FEEDENTRIES - insert into hibernate_sequences(sequence_name, sequence_next_hi_value) select 'FEEDENTRYCONTENTS', COALESCE(ceiling(max(id) / 1000 + 2), 1) from FEEDENTRYCONTENTS - insert into hibernate_sequences(sequence_name, sequence_next_hi_value) select 'FEEDENTRYSTATUSES', COALESCE(ceiling(max(id) / 1000 + 2), 1) from FEEDENTRYSTATUSES - insert into hibernate_sequences(sequence_name, sequence_next_hi_value) select 'FEEDS', COALESCE(ceiling(max(id) / 1000 + 2), 1) from FEEDS - insert into hibernate_sequences(sequence_name, sequence_next_hi_value) select 'FEEDSUBSCRIPTIONS', COALESCE(ceiling(max(id) / 1000 + 2), 1) from FEEDSUBSCRIPTIONS - insert into hibernate_sequences(sequence_name, sequence_next_hi_value) select 'USERROLES', COALESCE(ceiling(max(id) / 1000 + 2), 1) from USERROLES - insert into hibernate_sequences(sequence_name, sequence_next_hi_value) select 'USERS', COALESCE(ceiling(max(id) / 1000 + 2), 1) from USERS - insert into hibernate_sequences(sequence_name, sequence_next_hi_value) select 'USERSETTINGS', COALESCE(ceiling(max(id) / 1000 + 2), 1) from USERSETTINGS - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - update USERSETTINGS set user_lang='nb' where user_lang='no' - - - - 3:b1bbf8d559ac25b785751704f2d24a91 - 7:5bd8b28aadce012b56f003539ce99957 - - - - - - - 7:ffca06665d2dc182bd3cb718e62e98f0 - 8:a1b2bfccb0b37fec8eb107220f76e3bd - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 8:4473505a94945268fcca0f2d77e4be4a - - - - - - - - - - - - - - - - - - - - - - 7:c73f70fbcbc8bb30f9629028ec8ddb06 - - - - - - - - - - - 7:d6b5ab6920948b0a84e614870128e2f5 - update FEEDENTRYSTATUSES SET entryUpdated = (select e.updated from FEEDENTRIES e where e.id = FEEDENTRYSTATUSES.entry_id) - - - - 7:4227fdf2e7b9fe8e59544d536a7ee963 - update FEEDENTRYSTATUSES SET user_id = (select sub.user_id from FEEDSUBSCRIPTIONS sub where sub.id = FEEDENTRYSTATUSES.subscription_id) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - update FEED_FEEDENTRIES SET entryUpdated = (select e.updated from FEEDENTRIES e where e.id = FEED_FEEDENTRIES.feedentry_id) - - - - - - - - - - - - - - - - - - - - - - - - - - - - 7:cf40ae235c2d4086c5fa6ac64102c6a9 - - read_status = false and starred = false - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 7:094e881ed7e4fa14fb1f61147ce44acc + 7:f4bf2004a72bb41daa128df5771af902 + + + + + + + + + + insert into hibernate_sequences(sequence_name, sequence_next_hi_value) select 'APPLICATIONSETTINGS', COALESCE(ceiling(max(id) / 1000 + 2), 1) from APPLICATIONSETTINGS + insert into hibernate_sequences(sequence_name, sequence_next_hi_value) select 'FEEDCATEGORIES', COALESCE(ceiling(max(id) / 1000 + 2), 1) from FEEDCATEGORIES + insert into hibernate_sequences(sequence_name, sequence_next_hi_value) select 'FEEDENTRIES', COALESCE(ceiling(max(id) / 1000 + 2), 1) from FEEDENTRIES + insert into hibernate_sequences(sequence_name, sequence_next_hi_value) select 'FEEDENTRYCONTENTS', COALESCE(ceiling(max(id) / 1000 + 2), 1) from FEEDENTRYCONTENTS + insert into hibernate_sequences(sequence_name, sequence_next_hi_value) select 'FEEDENTRYSTATUSES', COALESCE(ceiling(max(id) / 1000 + 2), 1) from FEEDENTRYSTATUSES + insert into hibernate_sequences(sequence_name, sequence_next_hi_value) select 'FEEDS', COALESCE(ceiling(max(id) / 1000 + 2), 1) from FEEDS + insert into hibernate_sequences(sequence_name, sequence_next_hi_value) select 'FEEDSUBSCRIPTIONS', COALESCE(ceiling(max(id) / 1000 + 2), 1) from FEEDSUBSCRIPTIONS + insert into hibernate_sequences(sequence_name, sequence_next_hi_value) select 'USERROLES', COALESCE(ceiling(max(id) / 1000 + 2), 1) from USERROLES + insert into hibernate_sequences(sequence_name, sequence_next_hi_value) select 'USERS', COALESCE(ceiling(max(id) / 1000 + 2), 1) from USERS + insert into hibernate_sequences(sequence_name, sequence_next_hi_value) select 'USERSETTINGS', COALESCE(ceiling(max(id) / 1000 + 2), 1) from USERSETTINGS + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + update USERSETTINGS set user_lang='nb' where user_lang='no' + + + + 3:b1bbf8d559ac25b785751704f2d24a91 + 7:5bd8b28aadce012b56f003539ce99957 + + + + + + + 7:ffca06665d2dc182bd3cb718e62e98f0 + 8:a1b2bfccb0b37fec8eb107220f76e3bd + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 8:4473505a94945268fcca0f2d77e4be4a + + + + + + + + + + + + + + + + + + + + + + 7:c73f70fbcbc8bb30f9629028ec8ddb06 + + + + + + + + + + + 7:d6b5ab6920948b0a84e614870128e2f5 + update FEEDENTRYSTATUSES SET entryUpdated = (select e.updated from FEEDENTRIES e where e.id = FEEDENTRYSTATUSES.entry_id) + + + + 7:4227fdf2e7b9fe8e59544d536a7ee963 + update FEEDENTRYSTATUSES SET user_id = (select sub.user_id from FEEDSUBSCRIPTIONS sub where sub.id = FEEDENTRYSTATUSES.subscription_id) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + update FEED_FEEDENTRIES SET entryUpdated = (select e.updated from FEEDENTRIES e where e.id = FEED_FEEDENTRIES.feedentry_id) + + + + + + + + + + + + + + + + + + + + + + + + + + + + 7:cf40ae235c2d4086c5fa6ac64102c6a9 + + read_status = false and starred = false + + + + diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-1.2.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-1.2.xml index c735f521..5538f6b3 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-1.2.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-1.2.xml @@ -1,153 +1,153 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 7:9bf9357b47d8666dc7916f9a318138ad - 7:625f651e4c4d8e0aa9576da291baf6a4 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 7:9bf9357b47d8666dc7916f9a318138ad + 7:625f651e4c4d8e0aa9576da291baf6a4 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-1.3.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-1.3.xml index 3e5f07d2..da5e3f70 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-1.3.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-1.3.xml @@ -1,12 +1,12 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-1.4.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-1.4.xml index 578c0bc0..04bb9371 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-1.4.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-1.4.xml @@ -1,52 +1,52 @@ - - - - - - - - - - - - - - - - 7:fdd37bdee09c8fbbcbcd867b05decaae - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + 7:fdd37bdee09c8fbbcbcd867b05decaae + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-1.5.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-1.5.xml index 2a8f58f7..50966425 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-1.5.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-1.5.xml @@ -1,59 +1,59 @@ - - - - - 8:58e8060bba0ec9d448f4346eb35d815c - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + 8:58e8060bba0ec9d448f4346eb35d815c + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-2.1.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-2.1.xml index 6125c98e..5def7333 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-2.1.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-2.1.xml @@ -1,12 +1,12 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-2.2.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-2.2.xml index 6fb75000..27117757 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-2.2.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-2.2.xml @@ -1,12 +1,12 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-2.6.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-2.6.xml index 29e7fd95..615bff7d 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-2.6.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-2.6.xml @@ -1,32 +1,32 @@ - - - - - - - - - - - - - - - - - - - - - - - - - 8:39e5a9ff312af90d82f87c88abf1c66d - - - - + + + + + + + + + + + + + + + + + + + + + + + + + 8:39e5a9ff312af90d82f87c88abf1c66d + + + + diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-3.2.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-3.2.xml index 9d337166..d689ffcc 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-3.2.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-3.2.xml @@ -1,18 +1,18 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-3.5.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-3.5.xml index 87835d25..27dc7144 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-3.5.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-3.5.xml @@ -1,47 +1,47 @@ - - - - - - - update hibernate_sequences - set sequence_next_hi_value= - (select coalesce(max(id), 0) + 1 from FEEDCATEGORIES) - where sequence_name = 'FEEDCATEGORIES' - update hibernate_sequences - set sequence_next_hi_value= - (select coalesce(max(id), 0) + 1 from FEEDENTRIES) - where sequence_name = 'FEEDENTRIES' - update hibernate_sequences - set sequence_next_hi_value= - (select coalesce(max(id), 0) + 1 from FEEDENTRYCONTENTS) - where sequence_name = 'FEEDENTRYCONTENTS' - update hibernate_sequences - set sequence_next_hi_value= - (select coalesce(max(id), 0) + 1 from FEEDENTRYSTATUSES) - where sequence_name = 'FEEDENTRYSTATUSES' - update hibernate_sequences - set sequence_next_hi_value= - (select coalesce(max(id), 0) + 1 from FEEDS) - where sequence_name = 'FEEDS' - update hibernate_sequences - set sequence_next_hi_value= - (select coalesce(max(id), 0) + 1 from FEEDSUBSCRIPTIONS) - where sequence_name = 'FEEDSUBSCRIPTIONS' - update hibernate_sequences - set sequence_next_hi_value= - (select coalesce(max(id), 0) + 1 from USERROLES) - where sequence_name = 'USERROLES' - update hibernate_sequences - set sequence_next_hi_value= - (select coalesce(max(id), 0) + 1 from USERS) - where sequence_name = 'USERS' - update hibernate_sequences - set sequence_next_hi_value= - (select coalesce(max(id), 0) + 1 from USERSETTINGS) - where sequence_name = 'USERSETTINGS' - - - + + + + + + + update hibernate_sequences + set sequence_next_hi_value= + (select coalesce(max(id), 0) + 1 from FEEDCATEGORIES) + where sequence_name = 'FEEDCATEGORIES' + update hibernate_sequences + set sequence_next_hi_value= + (select coalesce(max(id), 0) + 1 from FEEDENTRIES) + where sequence_name = 'FEEDENTRIES' + update hibernate_sequences + set sequence_next_hi_value= + (select coalesce(max(id), 0) + 1 from FEEDENTRYCONTENTS) + where sequence_name = 'FEEDENTRYCONTENTS' + update hibernate_sequences + set sequence_next_hi_value= + (select coalesce(max(id), 0) + 1 from FEEDENTRYSTATUSES) + where sequence_name = 'FEEDENTRYSTATUSES' + update hibernate_sequences + set sequence_next_hi_value= + (select coalesce(max(id), 0) + 1 from FEEDS) + where sequence_name = 'FEEDS' + update hibernate_sequences + set sequence_next_hi_value= + (select coalesce(max(id), 0) + 1 from FEEDSUBSCRIPTIONS) + where sequence_name = 'FEEDSUBSCRIPTIONS' + update hibernate_sequences + set sequence_next_hi_value= + (select coalesce(max(id), 0) + 1 from USERROLES) + where sequence_name = 'USERROLES' + update hibernate_sequences + set sequence_next_hi_value= + (select coalesce(max(id), 0) + 1 from USERS) + where sequence_name = 'USERS' + update hibernate_sequences + set sequence_next_hi_value= + (select coalesce(max(id), 0) + 1 from USERSETTINGS) + where sequence_name = 'USERSETTINGS' + + + diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-3.6.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-3.6.xml index c3c1c916..16e7b562 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-3.6.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-3.6.xml @@ -1,25 +1,25 @@ - - - - - - - - position is null - - - - - - position is null - - - - - - - - - + + + + + + + + position is null + + + + + + position is null + + + + + + + + + diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-3.8.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-3.8.xml index 52bd6b36..f445a4e1 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-3.8.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-3.8.xml @@ -1,14 +1,14 @@ - - - - - - - - - - - - + + + + + + + + + + + + diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-3.9.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-3.9.xml index f2c386a3..3c9aca73 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-3.9.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-3.9.xml @@ -1,22 +1,22 @@ - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-4.0.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-4.0.xml index 3d3d6717..780157ea 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-4.0.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-4.0.xml @@ -1,13 +1,13 @@ - - - - - - - - - - - + + + + + + + + + + + diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-4.1.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-4.1.xml index 9c00a813..4acff884 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-4.1.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-4.1.xml @@ -1,13 +1,13 @@ - - - - - - - - - - - + + + + + + + + + + + diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-4.2.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-4.2.xml index 0af84537..ac200855 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-4.2.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-4.2.xml @@ -1,29 +1,29 @@ - - - - - 9:bf66bf7def9ec3dab1f365f7230d92cf - - - - - - - - - - - - - - - - - - - - - - + + + + + 9:bf66bf7def9ec3dab1f365f7230d92cf + + + + + + + + + + + + + + + + + + + + + + diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-4.3.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-4.3.xml index a9d696c1..7d8d8159 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-4.3.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-4.3.xml @@ -1,23 +1,23 @@ - - - - - 9:663bcc7c6df5b832ec2109a3afcff5c6 - - - - - - alwaysScrollToEntry = true - - - - alwaysScrollToEntry = false - - - - - - + + + + + 9:663bcc7c6df5b832ec2109a3afcff5c6 + + + + + + alwaysScrollToEntry = true + + + + alwaysScrollToEntry = false + + + + + + diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-4.4.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-4.4.xml index c15ed003..9652c333 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-4.4.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-4.4.xml @@ -1,18 +1,18 @@ - - - - - 9:078593b238a4639a97a3cd82f7e5e30d - - - - - - - - - - - + + + + + 9:078593b238a4639a97a3cd82f7e5e30d + + + + + + + + + + + diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-5.1.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-5.1.xml index 7e1b021e..ede9d895 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-5.1.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-5.1.xml @@ -1,29 +1,29 @@ - - - - - 9:b4a4044ca0f7d9987536083943d4f1b4 - - - - - - - - - - - - - user_id not in (select id from USERS) - - - - - + + + + + 9:b4a4044ca0f7d9987536083943d4f1b4 + + + + + + + + + + + + + user_id not in (select id from USERS) + + + + + diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-5.2.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-5.2.xml index 83834ddf..3076b8e7 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-5.2.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-5.2.xml @@ -1,19 +1,19 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/commafeed-server/src/main/resources/changelogs/db.changelog-5.3.xml b/commafeed-server/src/main/resources/changelogs/db.changelog-5.3.xml index 04b5a299..8845b678 100644 --- a/commafeed-server/src/main/resources/changelogs/db.changelog-5.3.xml +++ b/commafeed-server/src/main/resources/changelogs/db.changelog-5.3.xml @@ -1,11 +1,11 @@ - - - - - - - - - + + + + + + + + + diff --git a/commafeed-server/src/main/resources/default_banner.txt b/commafeed-server/src/main/resources/default_banner.txt index 3c450350..a272a64b 100644 --- a/commafeed-server/src/main/resources/default_banner.txt +++ b/commafeed-server/src/main/resources/default_banner.txt @@ -1,31 +1,31 @@ - - .',,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,.. - ';;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;. - .;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;. - ';;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;. - ';;;;;;;;;;;;;;;''''',,,;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:;. .':clllc;. - ';;;;;;;;;;;;;. ..',;;;;;;;;;;;;;;;;;;;;;;;;;;:;. .:dd:'...,lxo. - ';;;;;;;;;;;:,. .',;;;;;;;;;;;;;;;;;;;;;;:;. .lx:. ,o: ..... . ... .... . .... ... ..... - ';;;;;;;;;;;;;' .';;;;;;;;;;;;;;;;;;;;:;. ;ko ;oocccoo, .ooclcldd;:llcoxc. 'doclcldo;:lllod: .ldlccldc. - ';;;;;;;;;;;;;;;,'''.... .,;;;;;;;;;;;;;;;;;:;. ck: lx; :xc .dk; .dkc. cx; ,xx, 'xk:. .ox' ,:. ;x: - ';;;;;;;;;;;;;;;;;;;;;;;;;,.. .,;;;;;;;;;;;;;;;:;. :kc 'xl. .ox..do. .ox. ;x; ,xc .dd. cx, .':ccccok: - ';;;;;;;;;;;;;;,.....',,;;;;;;'. .;;;;;;;;;;;;;;:;. .dx' .cc.'xo. .ox..do. .ox. ;x; ,xc .dd. cx, .dd,. ;x: - ';;;;;;;;;;;;;. .',;;;;,. .,;;;;;;;;;;;;:;. 'dx:. .'ox; cx:. .:x: .do. .ox. ;x; ,xc .dd. cx, ;xc .ok: - ';;;;;;;;;;;;,. .';;;;,. .,;;;;;;;;;;;:;. .,lodooodoc. ,lolclol, .oc co. ,d, 'o: .ol. :d' .:dolllccdc - ';;;;;;;;;;;;;'. .';;;;. .,;;;;;;;;;;:;. ..... ... ... - ';;;;;;;;;;;;;;;;,,,'.. .;;;;' .;;;;;;;;;;:;. - ';;;;;;;;;;;;;;;;;;;;;;,. .;;;;. ';;;;;;;;;:;. .;ccccccccc:. .:. - ';;;;;;;;;;;;,'......';;;,. .;;;,. .;;;;;;;;;:;. .okc,'''''''' ;x: - ';;;;;;;;;;;. .,;;;. .,;;;. ,;;;;;;;;:;. .dx. .... .... .... ;x: - ';;;;;;;;;;' .;;;' ';;;' ';;;;;;;;:;. .dx. .;ol::coc. .coc::lo:. 'lolcccclx: - ';;;;;;;;;;. .,;;,. ,;;;,. ,;;;;;;;;:;. .dkdollllll:..od' .od. .do. 'do. ,xl. 'dk: - ';;;;;;;;;;,. .;;;;,. .';;;;;,. .,;;;;;;;;;:;. .dx' ;xxlcccccdx;.ckdlccccldd'.ox. ;x: - ';;;;;;;;;;;;'. .,;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:;. .dx. ,xl........ :x:........ lx' :x: - ';;;;;;;;;;;;;;;'. .;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:;. .dx. .ld, .:l. .oo. .cc. ,xo. ,dk: - ';;;;;;;;;;;;;;,. .';;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:;. .lo. .;oocclol' .:olccloc. .ldlcclccd; - ';;;;;;;;;;;;;;,,;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:;. .... .... ... - ';;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:;. - .;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;,. - .,;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;,. - ..''''''''''''''''''''''''''''''''''''''''''''''''... + + .',,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,.. + ';;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;. + .;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;. + ';;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;. + ';;;;;;;;;;;;;;;''''',,,;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:;. .':clllc;. + ';;;;;;;;;;;;;. ..',;;;;;;;;;;;;;;;;;;;;;;;;;;:;. .:dd:'...,lxo. + ';;;;;;;;;;;:,. .',;;;;;;;;;;;;;;;;;;;;;;:;. .lx:. ,o: ..... . ... .... . .... ... ..... + ';;;;;;;;;;;;;' .';;;;;;;;;;;;;;;;;;;;:;. ;ko ;oocccoo, .ooclcldd;:llcoxc. 'doclcldo;:lllod: .ldlccldc. + ';;;;;;;;;;;;;;;,'''.... .,;;;;;;;;;;;;;;;;;:;. ck: lx; :xc .dk; .dkc. cx; ,xx, 'xk:. .ox' ,:. ;x: + ';;;;;;;;;;;;;;;;;;;;;;;;;,.. .,;;;;;;;;;;;;;;;:;. :kc 'xl. .ox..do. .ox. ;x; ,xc .dd. cx, .':ccccok: + ';;;;;;;;;;;;;;,.....',,;;;;;;'. .;;;;;;;;;;;;;;:;. .dx' .cc.'xo. .ox..do. .ox. ;x; ,xc .dd. cx, .dd,. ;x: + ';;;;;;;;;;;;;. .',;;;;,. .,;;;;;;;;;;;;:;. 'dx:. .'ox; cx:. .:x: .do. .ox. ;x; ,xc .dd. cx, ;xc .ok: + ';;;;;;;;;;;;,. .';;;;,. .,;;;;;;;;;;;:;. .,lodooodoc. ,lolclol, .oc co. ,d, 'o: .ol. :d' .:dolllccdc + ';;;;;;;;;;;;;'. .';;;;. .,;;;;;;;;;;:;. ..... ... ... + ';;;;;;;;;;;;;;;;,,,'.. .;;;;' .;;;;;;;;;;:;. + ';;;;;;;;;;;;;;;;;;;;;;,. .;;;;. ';;;;;;;;;:;. .;ccccccccc:. .:. + ';;;;;;;;;;;;,'......';;;,. .;;;,. .;;;;;;;;;:;. .okc,'''''''' ;x: + ';;;;;;;;;;;. .,;;;. .,;;;. ,;;;;;;;;:;. .dx. .... .... .... ;x: + ';;;;;;;;;;' .;;;' ';;;' ';;;;;;;;:;. .dx. .;ol::coc. .coc::lo:. 'lolcccclx: + ';;;;;;;;;;. .,;;,. ,;;;,. ,;;;;;;;;:;. .dkdollllll:..od' .od. .do. 'do. ,xl. 'dk: + ';;;;;;;;;;,. .;;;;,. .';;;;;,. .,;;;;;;;;;:;. .dx' ;xxlcccccdx;.ckdlccccldd'.ox. ;x: + ';;;;;;;;;;;;'. .,;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:;. .dx. ,xl........ :x:........ lx' :x: + ';;;;;;;;;;;;;;;'. .;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:;. .dx. .ld, .:l. .oo. .cc. ,xo. ,dk: + ';;;;;;;;;;;;;;,. .';;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:;. .lo. .;oocclol' .:olccloc. .ldlcclccd; + ';;;;;;;;;;;;;;,,;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:;. .... .... ... + ';;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:;. + .;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;,. + .,;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;,. + ..''''''''''''''''''''''''''''''''''''''''''''''''... \ No newline at end of file diff --git a/commafeed-server/src/main/resources/migrations.xml b/commafeed-server/src/main/resources/migrations.xml index b0fd9d4c..75fd9136 100644 --- a/commafeed-server/src/main/resources/migrations.xml +++ b/commafeed-server/src/main/resources/migrations.xml @@ -1,38 +1,38 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/commafeed-server/src/main/resources/rome.properties b/commafeed-server/src/main/resources/rome.properties index 003d497b..7e3d5992 100644 --- a/commafeed-server/src/main/resources/rome.properties +++ b/commafeed-server/src/main/resources/rome.properties @@ -1,8 +1,8 @@ -WireFeedParser.classes= com.commafeed.backend.rome.OPML11Parser \ - com.commafeed.backend.rome.RSSRDF10Parser \ - com.commafeed.backend.rome.RSS090DescriptionParser - - -Converter.classes= com.commafeed.backend.rome.RSS090DescriptionConverter - +WireFeedParser.classes= com.commafeed.backend.rome.OPML11Parser \ + com.commafeed.backend.rome.RSSRDF10Parser \ + com.commafeed.backend.rome.RSS090DescriptionParser + + +Converter.classes= com.commafeed.backend.rome.RSS090DescriptionConverter + WireFeedGenerator.classes= com.commafeed.backend.rome.OPML11Generator \ No newline at end of file diff --git a/commafeed-server/src/test/java/com/commafeed/DatabaseReset.java b/commafeed-server/src/test/java/com/commafeed/DatabaseReset.java index 9f5ae03b..fa4deb28 100644 --- a/commafeed-server/src/test/java/com/commafeed/DatabaseReset.java +++ b/commafeed-server/src/test/java/com/commafeed/DatabaseReset.java @@ -1,24 +1,24 @@ -package com.commafeed; - -import jakarta.enterprise.inject.spi.CDI; - -import org.kohsuke.MetaInfServices; - -import com.commafeed.backend.service.db.DatabaseStartupService; - -import io.quarkus.liquibase.runtime.LiquibaseSchemaProvider; -import io.quarkus.test.junit.callback.QuarkusTestBeforeEachCallback; -import io.quarkus.test.junit.callback.QuarkusTestMethodContext; - -/** - * Resets database between tests - */ -@MetaInfServices -public class DatabaseReset implements QuarkusTestBeforeEachCallback { - - @Override - public void beforeEach(QuarkusTestMethodContext context) { - new LiquibaseSchemaProvider().resetAllDatabases(); - CDI.current().select(DatabaseStartupService.class).get().populateInitialData(); - } -} +package com.commafeed; + +import jakarta.enterprise.inject.spi.CDI; + +import org.kohsuke.MetaInfServices; + +import com.commafeed.backend.service.db.DatabaseStartupService; + +import io.quarkus.liquibase.runtime.LiquibaseSchemaProvider; +import io.quarkus.test.junit.callback.QuarkusTestBeforeEachCallback; +import io.quarkus.test.junit.callback.QuarkusTestMethodContext; + +/** + * Resets database between tests + */ +@MetaInfServices +public class DatabaseReset implements QuarkusTestBeforeEachCallback { + + @Override + public void beforeEach(QuarkusTestMethodContext context) { + new LiquibaseSchemaProvider().resetAllDatabases(); + CDI.current().select(DatabaseStartupService.class).get().populateInitialData(); + } +} diff --git a/commafeed-server/src/test/java/com/commafeed/NativeImageClassesTest.java b/commafeed-server/src/test/java/com/commafeed/NativeImageClassesTest.java index 4c961d02..1c2cf213 100644 --- a/commafeed-server/src/test/java/com/commafeed/NativeImageClassesTest.java +++ b/commafeed-server/src/test/java/com/commafeed/NativeImageClassesTest.java @@ -1,43 +1,43 @@ -package com.commafeed; - -import java.lang.reflect.Modifier; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.reflections.Reflections; -import org.reflections.scanners.Scanners; - -import com.rometools.rome.feed.CopyFrom; -import com.rometools.rome.feed.module.Module; -import com.rometools.rome.io.WireFeedGenerator; -import com.rometools.rome.io.WireFeedParser; - -import io.quarkus.runtime.annotations.RegisterForReflection; - -class NativeImageClassesTest { - - @Test - void annotationContainsAllRequiredRomeClasses() { - Reflections reflections = new Reflections("com.rometools"); - Set> classesInAnnotation = Set - .copyOf(List.of(NativeImageClasses.class.getAnnotation(RegisterForReflection.class).targets())); - - List> missingClasses = new ArrayList<>(); - for (Class clazz : List.of(Module.class, Cloneable.class, CopyFrom.class, WireFeedParser.class, WireFeedGenerator.class)) { - Set> moduleClasses = new HashSet<>(reflections.get(Scanners.SubTypes.of(clazz).asClass())); - moduleClasses.removeIf(c -> c.isInterface() || Modifier.isAbstract(c.getModifiers()) || !Modifier.isPublic(c.getModifiers())); - moduleClasses.removeAll(classesInAnnotation); - missingClasses.addAll(moduleClasses); - } - - missingClasses.sort(Comparator.comparing(Class::getName)); - missingClasses.forEach(c -> System.out.println(c.getName() + ".class,")); - Assertions.assertEquals(List.of(), missingClasses); - } - +package com.commafeed; + +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.reflections.Reflections; +import org.reflections.scanners.Scanners; + +import com.rometools.rome.feed.CopyFrom; +import com.rometools.rome.feed.module.Module; +import com.rometools.rome.io.WireFeedGenerator; +import com.rometools.rome.io.WireFeedParser; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +class NativeImageClassesTest { + + @Test + void annotationContainsAllRequiredRomeClasses() { + Reflections reflections = new Reflections("com.rometools"); + Set> classesInAnnotation = Set + .copyOf(List.of(NativeImageClasses.class.getAnnotation(RegisterForReflection.class).targets())); + + List> missingClasses = new ArrayList<>(); + for (Class clazz : List.of(Module.class, Cloneable.class, CopyFrom.class, WireFeedParser.class, WireFeedGenerator.class)) { + Set> moduleClasses = new HashSet<>(reflections.get(Scanners.SubTypes.of(clazz).asClass())); + moduleClasses.removeIf(c -> c.isInterface() || Modifier.isAbstract(c.getModifiers()) || !Modifier.isPublic(c.getModifiers())); + moduleClasses.removeAll(classesInAnnotation); + missingClasses.addAll(moduleClasses); + } + + missingClasses.sort(Comparator.comparing(Class::getName)); + missingClasses.forEach(c -> System.out.println(c.getName() + ".class,")); + Assertions.assertEquals(List.of(), missingClasses); + } + } \ No newline at end of file diff --git a/commafeed-server/src/test/java/com/commafeed/backend/DigestsTest.java b/commafeed-server/src/test/java/com/commafeed/backend/DigestsTest.java index c91a8845..e4ad91f8 100644 --- a/commafeed-server/src/test/java/com/commafeed/backend/DigestsTest.java +++ b/commafeed-server/src/test/java/com/commafeed/backend/DigestsTest.java @@ -1,18 +1,18 @@ -package com.commafeed.backend; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -class DigestsTest { - - @Test - void sha1Hex() { - Assertions.assertEquals("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d", Digests.sha1Hex("hello")); - } - - @Test - void md5Hex() { - Assertions.assertEquals("5d41402abc4b2a76b9719d911017c592", Digests.md5Hex("hello")); - } - +package com.commafeed.backend; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class DigestsTest { + + @Test + void sha1Hex() { + Assertions.assertEquals("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d", Digests.sha1Hex("hello")); + } + + @Test + void md5Hex() { + Assertions.assertEquals("5d41402abc4b2a76b9719d911017c592", Digests.md5Hex("hello")); + } + } \ No newline at end of file diff --git a/commafeed-server/src/test/java/com/commafeed/backend/HttpGetterTest.java b/commafeed-server/src/test/java/com/commafeed/backend/HttpGetterTest.java index a06541cd..57b35637 100644 --- a/commafeed-server/src/test/java/com/commafeed/backend/HttpGetterTest.java +++ b/commafeed-server/src/test/java/com/commafeed/backend/HttpGetterTest.java @@ -1,402 +1,402 @@ -package com.commafeed.backend; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.math.BigInteger; -import java.net.SocketTimeoutException; -import java.time.Duration; -import java.time.Instant; -import java.util.Arrays; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.zip.DeflaterOutputStream; -import java.util.zip.GZIPOutputStream; - -import org.apache.commons.io.IOUtils; -import org.apache.hc.client5.http.ConnectTimeoutException; -import org.apache.hc.core5.http.HttpStatus; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.Mockito; -import org.mockserver.client.MockServerClient; -import org.mockserver.junit.jupiter.MockServerExtension; -import org.mockserver.model.ConnectionOptions; -import org.mockserver.model.Delay; -import org.mockserver.model.HttpRequest; -import org.mockserver.model.HttpResponse; -import org.mockserver.model.MediaType; - -import com.codahale.metrics.MetricRegistry; -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.CommaFeedVersion; -import com.commafeed.backend.HttpGetter.HttpResponseException; -import com.commafeed.backend.HttpGetter.HttpResult; -import com.commafeed.backend.HttpGetter.NotModifiedException; -import com.commafeed.backend.HttpGetter.TooManyRequestsException; -import com.google.common.net.HttpHeaders; - -import io.quarkus.runtime.configuration.MemorySize; - -@ExtendWith(MockServerExtension.class) -class HttpGetterTest { - - private static final Instant NOW = Instant.now(); - - private MockServerClient mockServerClient; - private String feedUrl; - private byte[] feedContent; - - private CommaFeedConfiguration config; - - private HttpGetter getter; - - @BeforeEach - void init(MockServerClient mockServerClient) throws IOException { - this.mockServerClient = mockServerClient; - this.mockServerClient.reset(); - this.feedUrl = "http://localhost:" + this.mockServerClient.getPort() + "/"; - this.feedContent = IOUtils.toByteArray(Objects.requireNonNull(getClass().getResource("/feed/rss.xml"))); - - this.config = Mockito.mock(CommaFeedConfiguration.class, Mockito.RETURNS_DEEP_STUBS); - Mockito.when(config.httpClient().userAgent()).thenReturn(Optional.of("http-getter-test")); - Mockito.when(config.httpClient().connectTimeout()).thenReturn(Duration.ofSeconds(30)); - Mockito.when(config.httpClient().sslHandshakeTimeout()).thenReturn(Duration.ofSeconds(30)); - Mockito.when(config.httpClient().socketTimeout()).thenReturn(Duration.ofSeconds(30)); - Mockito.when(config.httpClient().responseTimeout()).thenReturn(Duration.ofSeconds(30)); - Mockito.when(config.httpClient().connectionTimeToLive()).thenReturn(Duration.ofSeconds(30)); - Mockito.when(config.httpClient().maxResponseSize()).thenReturn(new MemorySize(new BigInteger("10000"))); - Mockito.when(config.httpClient().cache().enabled()).thenReturn(true); - Mockito.when(config.httpClient().cache().maximumMemorySize()).thenReturn(new MemorySize(new BigInteger("100000"))); - Mockito.when(config.httpClient().cache().expiration()).thenReturn(Duration.ofMinutes(1)); - Mockito.when(config.feedRefresh().httpThreads()).thenReturn(3); - - this.getter = new HttpGetter(config, () -> NOW, Mockito.mock(CommaFeedVersion.class), Mockito.mock(MetricRegistry.class)); - } - - @ParameterizedTest - @ValueSource( - ints = { HttpStatus.SC_UNAUTHORIZED, HttpStatus.SC_FORBIDDEN, HttpStatus.SC_NOT_FOUND, HttpStatus.SC_INTERNAL_SERVER_ERROR }) - void errorCodes(int code) { - this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(HttpResponse.response().withStatusCode(code)); - - HttpResponseException e = Assertions.assertThrows(HttpResponseException.class, () -> getter.get(this.feedUrl)); - Assertions.assertEquals(code, e.getCode()); - } - - @Test - void validFeed() throws Exception { - this.mockServerClient.when(HttpRequest.request().withMethod("GET")) - .respond(HttpResponse.response() - .withBody(feedContent) - .withContentType(MediaType.APPLICATION_ATOM_XML) - .withHeader(HttpHeaders.LAST_MODIFIED, "123456") - .withHeader(HttpHeaders.ETAG, "78910") - .withHeader(HttpHeaders.CACHE_CONTROL, "max-age=60, must-revalidate") - .withHeader(HttpHeaders.RETRY_AFTER, "120")); - - HttpResult result = getter.get(this.feedUrl); - Assertions.assertArrayEquals(feedContent, result.getContent()); - Assertions.assertEquals(MediaType.APPLICATION_ATOM_XML.toString(), result.getContentType()); - Assertions.assertEquals("123456", result.getLastModifiedSince()); - Assertions.assertEquals("78910", result.getETag()); - Assertions.assertEquals(Duration.ofSeconds(60), result.getValidFor()); - Assertions.assertEquals(this.feedUrl, result.getUrlAfterRedirect()); - } - - @Test - void ignoreInvalidCacheControlValue() throws Exception { - this.mockServerClient.when(HttpRequest.request().withMethod("GET")) - .respond(HttpResponse.response() - .withBody(feedContent) - .withContentType(MediaType.APPLICATION_ATOM_XML) - .withHeader(HttpHeaders.CACHE_CONTROL, "max-age=60; must-revalidate")); - - HttpResult result = getter.get(this.feedUrl); - Assertions.assertEquals(Duration.ZERO, result.getValidFor()); - } - - @Test - void tooManyRequestsExceptionSeconds() { - this.mockServerClient.when(HttpRequest.request().withMethod("GET")) - .respond( - HttpResponse.response().withStatusCode(HttpStatus.SC_TOO_MANY_REQUESTS).withHeader(HttpHeaders.RETRY_AFTER, "120")); - - TooManyRequestsException e = Assertions.assertThrows(TooManyRequestsException.class, () -> getter.get(this.feedUrl)); - Assertions.assertEquals(NOW.plusSeconds(120), e.getRetryAfter()); - } - - @Test - void tooManyRequestsExceptionDate() { - this.mockServerClient.when(HttpRequest.request().withMethod("GET")) - .respond(HttpResponse.response() - .withStatusCode(HttpStatus.SC_TOO_MANY_REQUESTS) - .withHeader(HttpHeaders.RETRY_AFTER, "Wed, 21 Oct 2015 07:28:00 GMT")); - - TooManyRequestsException e = Assertions.assertThrows(TooManyRequestsException.class, () -> getter.get(this.feedUrl)); - Assertions.assertEquals(Instant.parse("2015-10-21T07:28:00Z"), e.getRetryAfter()); - } - - @ParameterizedTest - @ValueSource( - ints = { HttpStatus.SC_MOVED_PERMANENTLY, HttpStatus.SC_MOVED_TEMPORARILY, HttpStatus.SC_TEMPORARY_REDIRECT, - HttpStatus.SC_PERMANENT_REDIRECT }) - void followRedirects(int code) throws Exception { - // first redirect - this.mockServerClient.when(HttpRequest.request().withMethod("GET").withPath("/")) - .respond(HttpResponse.response() - .withStatusCode(code) - .withHeader(HttpHeaders.LOCATION, "http://localhost:" + this.mockServerClient.getPort() + "/redirected")); - - // second redirect - this.mockServerClient.when(HttpRequest.request().withMethod("GET").withPath("/redirected")) - .respond(HttpResponse.response() - .withStatusCode(code) - .withHeader(HttpHeaders.LOCATION, "http://localhost:" + this.mockServerClient.getPort() + "/redirected-2")); - - // final destination - this.mockServerClient.when(HttpRequest.request().withMethod("GET").withPath("/redirected-2")) - .respond(HttpResponse.response().withBody(feedContent).withContentType(MediaType.APPLICATION_ATOM_XML)); - - HttpResult result = getter.get(this.feedUrl); - Assertions.assertEquals("http://localhost:" + this.mockServerClient.getPort() + "/redirected-2", result.getUrlAfterRedirect()); - } - - @Test - void dataTimeout() { - Mockito.when(config.httpClient().responseTimeout()).thenReturn(Duration.ofMillis(500)); - this.getter = new HttpGetter(config, () -> NOW, Mockito.mock(CommaFeedVersion.class), Mockito.mock(MetricRegistry.class)); - - this.mockServerClient.when(HttpRequest.request().withMethod("GET")) - .respond(HttpResponse.response().withDelay(Delay.milliseconds(1000))); - - Assertions.assertThrows(SocketTimeoutException.class, () -> getter.get(this.feedUrl)); - } - - @Test - void connectTimeout() { - Mockito.when(config.httpClient().connectTimeout()).thenReturn(Duration.ofMillis(500)); - this.getter = new HttpGetter(config, () -> NOW, Mockito.mock(CommaFeedVersion.class), Mockito.mock(MetricRegistry.class)); - // try to connect to a non-routable address - // https://stackoverflow.com/a/904609 - Assertions.assertThrows(ConnectTimeoutException.class, () -> getter.get("http://10.255.255.1")); - } - - @Test - void userAgent() throws Exception { - this.mockServerClient.when(HttpRequest.request().withMethod("GET").withHeader(HttpHeaders.USER_AGENT, "http-getter-test")) - .respond(HttpResponse.response().withBody("ok")); - - HttpResult result = getter.get(this.feedUrl); - Assertions.assertEquals("ok", new String(result.getContent())); - } - - @Test - void lastModifiedReturns304() { - this.mockServerClient.when(HttpRequest.request().withMethod("GET").withHeader(HttpHeaders.IF_MODIFIED_SINCE, "123456")) - .respond(HttpResponse.response().withStatusCode(HttpStatus.SC_NOT_MODIFIED)); - - Assertions.assertThrows(NotModifiedException.class, - () -> getter.get(HttpGetter.HttpRequest.builder(this.feedUrl).lastModified("123456").build())); - } - - @Test - void eTagReturns304() { - this.mockServerClient.when(HttpRequest.request().withMethod("GET").withHeader(HttpHeaders.IF_NONE_MATCH, "78910")) - .respond(HttpResponse.response().withStatusCode(HttpStatus.SC_NOT_MODIFIED)); - - Assertions.assertThrows(NotModifiedException.class, - () -> getter.get(HttpGetter.HttpRequest.builder(this.feedUrl).eTag("78910").build())); - } - - @Test - void ignoreCookie() { - AtomicInteger calls = new AtomicInteger(); - - this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(req -> { - calls.incrementAndGet(); - - if (req.containsHeader(HttpHeaders.COOKIE)) { - throw new Exception("cookie should not be sent by the client"); - } - - return HttpResponse.response().withBody("ok").withHeader(HttpHeaders.SET_COOKIE, "foo=bar"); - }); - - Assertions.assertDoesNotThrow(() -> getter.get(this.feedUrl)); - Assertions.assertDoesNotThrow(() -> getter.get(this.feedUrl + "?foo=bar")); - Assertions.assertEquals(2, calls.get()); - } - - @Test - void cacheSubsequentCalls() throws Exception { - AtomicInteger calls = new AtomicInteger(); - - this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(req -> { - calls.incrementAndGet(); - return HttpResponse.response().withBody("ok"); - }); - - HttpResult result = getter.get(this.feedUrl); - Assertions.assertEquals(result, getter.get(this.feedUrl)); - Assertions.assertEquals(1, calls.get()); - } - - @Test - void largeFeedWithContentLengthHeader() { - byte[] bytes = new byte[100000]; - Arrays.fill(bytes, (byte) 1); - this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(HttpResponse.response().withBody(bytes)); - - IOException e = Assertions.assertThrows(IOException.class, () -> getter.get(this.feedUrl)); - Assertions.assertEquals("Response size (100000 bytes) exceeds the maximum allowed size (10000 bytes)", e.getMessage()); - } - - @Test - void largeFeedWithoutContentLengthHeader() { - byte[] bytes = new byte[100000]; - Arrays.fill(bytes, (byte) 1); - this.mockServerClient.when(HttpRequest.request().withMethod("GET")) - .respond(HttpResponse.response() - .withBody(bytes) - .withConnectionOptions(ConnectionOptions.connectionOptions().withSuppressContentLengthHeader(true))); - - IOException e = Assertions.assertThrows(IOException.class, () -> getter.get(this.feedUrl)); - Assertions.assertEquals("Response size exceeds the maximum allowed size (10000 bytes)", e.getMessage()); - } - - @Test - void ignoreInvalidSsl() throws Exception { - this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(HttpResponse.response().withBody("ok")); - - HttpResult result = getter.get("https://localhost:" + this.mockServerClient.getPort()); - Assertions.assertEquals("ok", new String(result.getContent())); - } - - @Test - void doesNotUseUpgradeProtocolHeader() { - AtomicInteger calls = new AtomicInteger(); - - this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(req -> { - calls.incrementAndGet(); - - if (req.containsHeader(HttpHeaders.UPGRADE)) { - throw new Exception("upgrade header should not be sent by the client"); - } - - return HttpResponse.response().withBody("ok"); - }); - - Assertions.assertDoesNotThrow(() -> getter.get(this.feedUrl)); - Assertions.assertEquals(1, calls.get()); - } - - @Nested - class Compression { - - @Test - void deflate() throws Exception { - supportsCompression("deflate", DeflaterOutputStream::new); - } - - @Test - void gzip() throws Exception { - supportsCompression("gzip", GZIPOutputStream::new); - } - - void supportsCompression(String encoding, CompressionOutputStreamFunction compressionOutputStreamFunction) throws Exception { - String body = "my body"; - - HttpGetterTest.this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(req -> { - String acceptEncodingHeader = req.getFirstHeader(HttpHeaders.ACCEPT_ENCODING); - if (!Set.of(acceptEncodingHeader.split(", ")).contains(encoding)) { - throw new Exception(encoding + " should be in the Accept-Encoding header"); - } - - ByteArrayOutputStream output = new ByteArrayOutputStream(); - try (OutputStream compressionOutputStream = compressionOutputStreamFunction.apply(output)) { - compressionOutputStream.write(body.getBytes()); - } - - return HttpResponse.response().withBody(output.toByteArray()).withHeader(HttpHeaders.CONTENT_ENCODING, encoding); - }); - - HttpResult result = getter.get(HttpGetterTest.this.feedUrl); - Assertions.assertEquals(body, new String(result.getContent())); - } - - @FunctionalInterface - public interface CompressionOutputStreamFunction { - OutputStream apply(OutputStream input) throws IOException; - } - - } - - @Nested - class SchemeNotAllowed { - @Test - void file() { - Assertions.assertThrows(HttpGetter.SchemeNotAllowedException.class, () -> getter.get("file://localhost")); - } - - @Test - void ftp() { - Assertions.assertThrows(HttpGetter.SchemeNotAllowedException.class, () -> getter.get("ftp://localhost")); - } - } - - @Nested - class HostNotAllowed { - - @BeforeEach - void init() { - Mockito.when(config.httpClient().blockLocalAddresses()).thenReturn(true); - getter = new HttpGetter(config, () -> NOW, Mockito.mock(CommaFeedVersion.class), Mockito.mock(MetricRegistry.class)); - } - - @Test - void localhost() { - Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://localhost")); - Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://127.0.0.1")); - Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://2130706433")); - Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://0x7F.0x00.0x00.0X01")); - } - - @Test - void zero() { - Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://0.0.0.0")); - } - - @Test - void linkLocal() { - Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://169.254.12.34")); - Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://169.254.169.254")); - } - - @Test - void multicast() { - Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://224.2.3.4")); - Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://239.255.255.254")); - } - - @Test - void privateIpv4Ranges() { - Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://10.0.0.1")); - Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://172.16.0.1")); - Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://192.168.0.1")); - } - - @Test - void privateIpv6Ranges() { - Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://fd12:3456:789a:1::1")); - } - } - +package com.commafeed.backend; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.math.BigInteger; +import java.net.SocketTimeoutException; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.GZIPOutputStream; + +import org.apache.commons.io.IOUtils; +import org.apache.hc.client5.http.ConnectTimeoutException; +import org.apache.hc.core5.http.HttpStatus; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mockito; +import org.mockserver.client.MockServerClient; +import org.mockserver.junit.jupiter.MockServerExtension; +import org.mockserver.model.ConnectionOptions; +import org.mockserver.model.Delay; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; +import org.mockserver.model.MediaType; + +import com.codahale.metrics.MetricRegistry; +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.CommaFeedVersion; +import com.commafeed.backend.HttpGetter.HttpResponseException; +import com.commafeed.backend.HttpGetter.HttpResult; +import com.commafeed.backend.HttpGetter.NotModifiedException; +import com.commafeed.backend.HttpGetter.TooManyRequestsException; +import com.google.common.net.HttpHeaders; + +import io.quarkus.runtime.configuration.MemorySize; + +@ExtendWith(MockServerExtension.class) +class HttpGetterTest { + + private static final Instant NOW = Instant.now(); + + private MockServerClient mockServerClient; + private String feedUrl; + private byte[] feedContent; + + private CommaFeedConfiguration config; + + private HttpGetter getter; + + @BeforeEach + void init(MockServerClient mockServerClient) throws IOException { + this.mockServerClient = mockServerClient; + this.mockServerClient.reset(); + this.feedUrl = "http://localhost:" + this.mockServerClient.getPort() + "/"; + this.feedContent = IOUtils.toByteArray(Objects.requireNonNull(getClass().getResource("/feed/rss.xml"))); + + this.config = Mockito.mock(CommaFeedConfiguration.class, Mockito.RETURNS_DEEP_STUBS); + Mockito.when(config.httpClient().userAgent()).thenReturn(Optional.of("http-getter-test")); + Mockito.when(config.httpClient().connectTimeout()).thenReturn(Duration.ofSeconds(30)); + Mockito.when(config.httpClient().sslHandshakeTimeout()).thenReturn(Duration.ofSeconds(30)); + Mockito.when(config.httpClient().socketTimeout()).thenReturn(Duration.ofSeconds(30)); + Mockito.when(config.httpClient().responseTimeout()).thenReturn(Duration.ofSeconds(30)); + Mockito.when(config.httpClient().connectionTimeToLive()).thenReturn(Duration.ofSeconds(30)); + Mockito.when(config.httpClient().maxResponseSize()).thenReturn(new MemorySize(new BigInteger("10000"))); + Mockito.when(config.httpClient().cache().enabled()).thenReturn(true); + Mockito.when(config.httpClient().cache().maximumMemorySize()).thenReturn(new MemorySize(new BigInteger("100000"))); + Mockito.when(config.httpClient().cache().expiration()).thenReturn(Duration.ofMinutes(1)); + Mockito.when(config.feedRefresh().httpThreads()).thenReturn(3); + + this.getter = new HttpGetter(config, () -> NOW, Mockito.mock(CommaFeedVersion.class), Mockito.mock(MetricRegistry.class)); + } + + @ParameterizedTest + @ValueSource( + ints = { HttpStatus.SC_UNAUTHORIZED, HttpStatus.SC_FORBIDDEN, HttpStatus.SC_NOT_FOUND, HttpStatus.SC_INTERNAL_SERVER_ERROR }) + void errorCodes(int code) { + this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(HttpResponse.response().withStatusCode(code)); + + HttpResponseException e = Assertions.assertThrows(HttpResponseException.class, () -> getter.get(this.feedUrl)); + Assertions.assertEquals(code, e.getCode()); + } + + @Test + void validFeed() throws Exception { + this.mockServerClient.when(HttpRequest.request().withMethod("GET")) + .respond(HttpResponse.response() + .withBody(feedContent) + .withContentType(MediaType.APPLICATION_ATOM_XML) + .withHeader(HttpHeaders.LAST_MODIFIED, "123456") + .withHeader(HttpHeaders.ETAG, "78910") + .withHeader(HttpHeaders.CACHE_CONTROL, "max-age=60, must-revalidate") + .withHeader(HttpHeaders.RETRY_AFTER, "120")); + + HttpResult result = getter.get(this.feedUrl); + Assertions.assertArrayEquals(feedContent, result.getContent()); + Assertions.assertEquals(MediaType.APPLICATION_ATOM_XML.toString(), result.getContentType()); + Assertions.assertEquals("123456", result.getLastModifiedSince()); + Assertions.assertEquals("78910", result.getETag()); + Assertions.assertEquals(Duration.ofSeconds(60), result.getValidFor()); + Assertions.assertEquals(this.feedUrl, result.getUrlAfterRedirect()); + } + + @Test + void ignoreInvalidCacheControlValue() throws Exception { + this.mockServerClient.when(HttpRequest.request().withMethod("GET")) + .respond(HttpResponse.response() + .withBody(feedContent) + .withContentType(MediaType.APPLICATION_ATOM_XML) + .withHeader(HttpHeaders.CACHE_CONTROL, "max-age=60; must-revalidate")); + + HttpResult result = getter.get(this.feedUrl); + Assertions.assertEquals(Duration.ZERO, result.getValidFor()); + } + + @Test + void tooManyRequestsExceptionSeconds() { + this.mockServerClient.when(HttpRequest.request().withMethod("GET")) + .respond( + HttpResponse.response().withStatusCode(HttpStatus.SC_TOO_MANY_REQUESTS).withHeader(HttpHeaders.RETRY_AFTER, "120")); + + TooManyRequestsException e = Assertions.assertThrows(TooManyRequestsException.class, () -> getter.get(this.feedUrl)); + Assertions.assertEquals(NOW.plusSeconds(120), e.getRetryAfter()); + } + + @Test + void tooManyRequestsExceptionDate() { + this.mockServerClient.when(HttpRequest.request().withMethod("GET")) + .respond(HttpResponse.response() + .withStatusCode(HttpStatus.SC_TOO_MANY_REQUESTS) + .withHeader(HttpHeaders.RETRY_AFTER, "Wed, 21 Oct 2015 07:28:00 GMT")); + + TooManyRequestsException e = Assertions.assertThrows(TooManyRequestsException.class, () -> getter.get(this.feedUrl)); + Assertions.assertEquals(Instant.parse("2015-10-21T07:28:00Z"), e.getRetryAfter()); + } + + @ParameterizedTest + @ValueSource( + ints = { HttpStatus.SC_MOVED_PERMANENTLY, HttpStatus.SC_MOVED_TEMPORARILY, HttpStatus.SC_TEMPORARY_REDIRECT, + HttpStatus.SC_PERMANENT_REDIRECT }) + void followRedirects(int code) throws Exception { + // first redirect + this.mockServerClient.when(HttpRequest.request().withMethod("GET").withPath("/")) + .respond(HttpResponse.response() + .withStatusCode(code) + .withHeader(HttpHeaders.LOCATION, "http://localhost:" + this.mockServerClient.getPort() + "/redirected")); + + // second redirect + this.mockServerClient.when(HttpRequest.request().withMethod("GET").withPath("/redirected")) + .respond(HttpResponse.response() + .withStatusCode(code) + .withHeader(HttpHeaders.LOCATION, "http://localhost:" + this.mockServerClient.getPort() + "/redirected-2")); + + // final destination + this.mockServerClient.when(HttpRequest.request().withMethod("GET").withPath("/redirected-2")) + .respond(HttpResponse.response().withBody(feedContent).withContentType(MediaType.APPLICATION_ATOM_XML)); + + HttpResult result = getter.get(this.feedUrl); + Assertions.assertEquals("http://localhost:" + this.mockServerClient.getPort() + "/redirected-2", result.getUrlAfterRedirect()); + } + + @Test + void dataTimeout() { + Mockito.when(config.httpClient().responseTimeout()).thenReturn(Duration.ofMillis(500)); + this.getter = new HttpGetter(config, () -> NOW, Mockito.mock(CommaFeedVersion.class), Mockito.mock(MetricRegistry.class)); + + this.mockServerClient.when(HttpRequest.request().withMethod("GET")) + .respond(HttpResponse.response().withDelay(Delay.milliseconds(1000))); + + Assertions.assertThrows(SocketTimeoutException.class, () -> getter.get(this.feedUrl)); + } + + @Test + void connectTimeout() { + Mockito.when(config.httpClient().connectTimeout()).thenReturn(Duration.ofMillis(500)); + this.getter = new HttpGetter(config, () -> NOW, Mockito.mock(CommaFeedVersion.class), Mockito.mock(MetricRegistry.class)); + // try to connect to a non-routable address + // https://stackoverflow.com/a/904609 + Assertions.assertThrows(ConnectTimeoutException.class, () -> getter.get("http://10.255.255.1")); + } + + @Test + void userAgent() throws Exception { + this.mockServerClient.when(HttpRequest.request().withMethod("GET").withHeader(HttpHeaders.USER_AGENT, "http-getter-test")) + .respond(HttpResponse.response().withBody("ok")); + + HttpResult result = getter.get(this.feedUrl); + Assertions.assertEquals("ok", new String(result.getContent())); + } + + @Test + void lastModifiedReturns304() { + this.mockServerClient.when(HttpRequest.request().withMethod("GET").withHeader(HttpHeaders.IF_MODIFIED_SINCE, "123456")) + .respond(HttpResponse.response().withStatusCode(HttpStatus.SC_NOT_MODIFIED)); + + Assertions.assertThrows(NotModifiedException.class, + () -> getter.get(HttpGetter.HttpRequest.builder(this.feedUrl).lastModified("123456").build())); + } + + @Test + void eTagReturns304() { + this.mockServerClient.when(HttpRequest.request().withMethod("GET").withHeader(HttpHeaders.IF_NONE_MATCH, "78910")) + .respond(HttpResponse.response().withStatusCode(HttpStatus.SC_NOT_MODIFIED)); + + Assertions.assertThrows(NotModifiedException.class, + () -> getter.get(HttpGetter.HttpRequest.builder(this.feedUrl).eTag("78910").build())); + } + + @Test + void ignoreCookie() { + AtomicInteger calls = new AtomicInteger(); + + this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(req -> { + calls.incrementAndGet(); + + if (req.containsHeader(HttpHeaders.COOKIE)) { + throw new Exception("cookie should not be sent by the client"); + } + + return HttpResponse.response().withBody("ok").withHeader(HttpHeaders.SET_COOKIE, "foo=bar"); + }); + + Assertions.assertDoesNotThrow(() -> getter.get(this.feedUrl)); + Assertions.assertDoesNotThrow(() -> getter.get(this.feedUrl + "?foo=bar")); + Assertions.assertEquals(2, calls.get()); + } + + @Test + void cacheSubsequentCalls() throws Exception { + AtomicInteger calls = new AtomicInteger(); + + this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(req -> { + calls.incrementAndGet(); + return HttpResponse.response().withBody("ok"); + }); + + HttpResult result = getter.get(this.feedUrl); + Assertions.assertEquals(result, getter.get(this.feedUrl)); + Assertions.assertEquals(1, calls.get()); + } + + @Test + void largeFeedWithContentLengthHeader() { + byte[] bytes = new byte[100000]; + Arrays.fill(bytes, (byte) 1); + this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(HttpResponse.response().withBody(bytes)); + + IOException e = Assertions.assertThrows(IOException.class, () -> getter.get(this.feedUrl)); + Assertions.assertEquals("Response size (100000 bytes) exceeds the maximum allowed size (10000 bytes)", e.getMessage()); + } + + @Test + void largeFeedWithoutContentLengthHeader() { + byte[] bytes = new byte[100000]; + Arrays.fill(bytes, (byte) 1); + this.mockServerClient.when(HttpRequest.request().withMethod("GET")) + .respond(HttpResponse.response() + .withBody(bytes) + .withConnectionOptions(ConnectionOptions.connectionOptions().withSuppressContentLengthHeader(true))); + + IOException e = Assertions.assertThrows(IOException.class, () -> getter.get(this.feedUrl)); + Assertions.assertEquals("Response size exceeds the maximum allowed size (10000 bytes)", e.getMessage()); + } + + @Test + void ignoreInvalidSsl() throws Exception { + this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(HttpResponse.response().withBody("ok")); + + HttpResult result = getter.get("https://localhost:" + this.mockServerClient.getPort()); + Assertions.assertEquals("ok", new String(result.getContent())); + } + + @Test + void doesNotUseUpgradeProtocolHeader() { + AtomicInteger calls = new AtomicInteger(); + + this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(req -> { + calls.incrementAndGet(); + + if (req.containsHeader(HttpHeaders.UPGRADE)) { + throw new Exception("upgrade header should not be sent by the client"); + } + + return HttpResponse.response().withBody("ok"); + }); + + Assertions.assertDoesNotThrow(() -> getter.get(this.feedUrl)); + Assertions.assertEquals(1, calls.get()); + } + + @Nested + class Compression { + + @Test + void deflate() throws Exception { + supportsCompression("deflate", DeflaterOutputStream::new); + } + + @Test + void gzip() throws Exception { + supportsCompression("gzip", GZIPOutputStream::new); + } + + void supportsCompression(String encoding, CompressionOutputStreamFunction compressionOutputStreamFunction) throws Exception { + String body = "my body"; + + HttpGetterTest.this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(req -> { + String acceptEncodingHeader = req.getFirstHeader(HttpHeaders.ACCEPT_ENCODING); + if (!Set.of(acceptEncodingHeader.split(", ")).contains(encoding)) { + throw new Exception(encoding + " should be in the Accept-Encoding header"); + } + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (OutputStream compressionOutputStream = compressionOutputStreamFunction.apply(output)) { + compressionOutputStream.write(body.getBytes()); + } + + return HttpResponse.response().withBody(output.toByteArray()).withHeader(HttpHeaders.CONTENT_ENCODING, encoding); + }); + + HttpResult result = getter.get(HttpGetterTest.this.feedUrl); + Assertions.assertEquals(body, new String(result.getContent())); + } + + @FunctionalInterface + public interface CompressionOutputStreamFunction { + OutputStream apply(OutputStream input) throws IOException; + } + + } + + @Nested + class SchemeNotAllowed { + @Test + void file() { + Assertions.assertThrows(HttpGetter.SchemeNotAllowedException.class, () -> getter.get("file://localhost")); + } + + @Test + void ftp() { + Assertions.assertThrows(HttpGetter.SchemeNotAllowedException.class, () -> getter.get("ftp://localhost")); + } + } + + @Nested + class HostNotAllowed { + + @BeforeEach + void init() { + Mockito.when(config.httpClient().blockLocalAddresses()).thenReturn(true); + getter = new HttpGetter(config, () -> NOW, Mockito.mock(CommaFeedVersion.class), Mockito.mock(MetricRegistry.class)); + } + + @Test + void localhost() { + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://localhost")); + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://127.0.0.1")); + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://2130706433")); + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://0x7F.0x00.0x00.0X01")); + } + + @Test + void zero() { + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://0.0.0.0")); + } + + @Test + void linkLocal() { + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://169.254.12.34")); + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://169.254.169.254")); + } + + @Test + void multicast() { + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://224.2.3.4")); + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://239.255.255.254")); + } + + @Test + void privateIpv4Ranges() { + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://10.0.0.1")); + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://172.16.0.1")); + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://192.168.0.1")); + } + + @Test + void privateIpv6Ranges() { + Assertions.assertThrows(HttpGetter.HostNotAllowedException.class, () -> getter.get("http://fd12:3456:789a:1::1")); + } + } + } \ No newline at end of file diff --git a/commafeed-server/src/test/java/com/commafeed/backend/feed/FeedFetcherTest.java b/commafeed-server/src/test/java/com/commafeed/backend/feed/FeedFetcherTest.java index eedd7ae8..c71f1a21 100644 --- a/commafeed-server/src/test/java/com/commafeed/backend/feed/FeedFetcherTest.java +++ b/commafeed-server/src/test/java/com/commafeed/backend/feed/FeedFetcherTest.java @@ -1,60 +1,60 @@ -package com.commafeed.backend.feed; - -import java.time.Duration; -import java.time.Instant; -import java.util.List; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.junit.jupiter.MockitoExtension; - -import com.commafeed.backend.Digests; -import com.commafeed.backend.HttpGetter; -import com.commafeed.backend.HttpGetter.HttpResult; -import com.commafeed.backend.HttpGetter.NotModifiedException; -import com.commafeed.backend.feed.parser.FeedParser; -import com.commafeed.backend.urlprovider.FeedURLProvider; - -@ExtendWith(MockitoExtension.class) -class FeedFetcherTest { - - @Mock - private FeedParser parser; - - @Mock - private HttpGetter getter; - - @Mock - private List urlProviders; - - private FeedFetcher fetcher; - - @BeforeEach - void init() { - fetcher = new FeedFetcher(parser, getter, urlProviders); - } - - @Test - void updatesHeaderWhenContentDitNotChange() throws Exception { - String url = "https://aaa.com"; - String lastModified = "last-modified-1"; - String etag = "etag-1"; - byte[] content = "content".getBytes(); - String lastContentHash = Digests.sha1Hex(content); - - Mockito.when(getter.get(HttpGetter.HttpRequest.builder(url).lastModified(lastModified).eTag(etag).build())) - .thenReturn(new HttpResult(content, "content-type", "last-modified-2", "etag-2", null, Duration.ZERO)); - - NotModifiedException e = Assertions.assertThrows(NotModifiedException.class, - () -> fetcher.fetch(url, false, lastModified, etag, Instant.now(), lastContentHash)); - - Assertions.assertEquals("last-modified-2", e.getNewLastModifiedHeader()); - Assertions.assertEquals("etag-2", e.getNewEtagHeader()); - - } - -} +package com.commafeed.backend.feed; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.commafeed.backend.Digests; +import com.commafeed.backend.HttpGetter; +import com.commafeed.backend.HttpGetter.HttpResult; +import com.commafeed.backend.HttpGetter.NotModifiedException; +import com.commafeed.backend.feed.parser.FeedParser; +import com.commafeed.backend.urlprovider.FeedURLProvider; + +@ExtendWith(MockitoExtension.class) +class FeedFetcherTest { + + @Mock + private FeedParser parser; + + @Mock + private HttpGetter getter; + + @Mock + private List urlProviders; + + private FeedFetcher fetcher; + + @BeforeEach + void init() { + fetcher = new FeedFetcher(parser, getter, urlProviders); + } + + @Test + void updatesHeaderWhenContentDitNotChange() throws Exception { + String url = "https://aaa.com"; + String lastModified = "last-modified-1"; + String etag = "etag-1"; + byte[] content = "content".getBytes(); + String lastContentHash = Digests.sha1Hex(content); + + Mockito.when(getter.get(HttpGetter.HttpRequest.builder(url).lastModified(lastModified).eTag(etag).build())) + .thenReturn(new HttpResult(content, "content-type", "last-modified-2", "etag-2", null, Duration.ZERO)); + + NotModifiedException e = Assertions.assertThrows(NotModifiedException.class, + () -> fetcher.fetch(url, false, lastModified, etag, Instant.now(), lastContentHash)); + + Assertions.assertEquals("last-modified-2", e.getNewLastModifiedHeader()); + Assertions.assertEquals("etag-2", e.getNewEtagHeader()); + + } + +} diff --git a/commafeed-server/src/test/java/com/commafeed/backend/feed/FeedRefreshIntervalCalculatorTest.java b/commafeed-server/src/test/java/com/commafeed/backend/feed/FeedRefreshIntervalCalculatorTest.java index d31c1764..37a22a89 100644 --- a/commafeed-server/src/test/java/com/commafeed/backend/feed/FeedRefreshIntervalCalculatorTest.java +++ b/commafeed-server/src/test/java/com/commafeed/backend/feed/FeedRefreshIntervalCalculatorTest.java @@ -1,278 +1,278 @@ -package com.commafeed.backend.feed; - -import java.time.Duration; -import java.time.Instant; -import java.time.InstantSource; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.junit.jupiter.MockitoExtension; - -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.CommaFeedConfiguration.FeedRefreshErrorHandling; - -@ExtendWith(MockitoExtension.class) -class FeedRefreshIntervalCalculatorTest { - - private static final Instant NOW = Instant.now(); - private static final Duration DEFAULT_INTERVAL = Duration.ofHours(1); - private static final Duration MAX_INTERVAL = Duration.ofDays(1); - - @Mock - private InstantSource instantSource; - - @Mock - private CommaFeedConfiguration config; - - @Mock - private FeedRefreshErrorHandling errorHandling; - - private FeedRefreshIntervalCalculator calculator; - - @BeforeEach - void setUp() { - Mockito.when(instantSource.instant()).thenReturn(NOW); - Mockito.when(config.feedRefresh()).thenReturn(Mockito.mock(CommaFeedConfiguration.FeedRefresh.class)); - Mockito.when(config.feedRefresh().interval()).thenReturn(DEFAULT_INTERVAL); - Mockito.when(config.feedRefresh().maxInterval()).thenReturn(MAX_INTERVAL); - Mockito.when(config.feedRefresh().errors()).thenReturn(errorHandling); - - calculator = new FeedRefreshIntervalCalculator(config, instantSource); - } - - @Nested - class FetchSuccess { - - @Nested - class EmpiricalDisabled { - @ParameterizedTest - @ValueSource(longs = { 0, 1, 300, 86400000L }) - void withoutValidFor(long averageEntryInterval) { - // averageEntryInterval is ignored when empirical is disabled - Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), averageEntryInterval, Duration.ZERO); - Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result); - } - - @Test - void withValidForGreaterThanMaxInterval() { - Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), 1L, MAX_INTERVAL.plusDays(1)); - Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result); - } - - @Test - void withValidForLowerThanMaxInterval() { - Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), 1L, MAX_INTERVAL.minusSeconds(1)); - Assertions.assertEquals(NOW.plus(MAX_INTERVAL).minusSeconds(1), result); - } - } - - @Nested - class EmpiricalEnabled { - @BeforeEach - void setUp() { - Mockito.when(config.feedRefresh().intervalEmpirical()).thenReturn(true); - calculator = new FeedRefreshIntervalCalculator(config, instantSource); - } - - @Test - void withNullPublishedDate() { - Instant result = calculator.onFetchSuccess(null, 1L, Duration.ZERO); - Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result); - } - - @Test - void with31DaysOldPublishedDate() { - Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(31)), 1L, Duration.ZERO); - Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result); - } - - @Test - void with15DaysOldPublishedDate() { - Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(15)), 1L, Duration.ZERO); - Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(2)), result); - } - - @Test - void with8DaysOldPublishedDate() { - Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(8)), 1L, Duration.ZERO); - Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(4)), result); - } - - @Nested - class FiveDaysOld { - @Test - void averageBetweenBounds() { - Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), Duration.ofHours(4).toMillis(), - Duration.ZERO); - Assertions.assertEquals(NOW.plus(Duration.ofHours(2)), result); - } - - @Test - void averageBelowMinimum() { - Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), 10L, Duration.ZERO); - Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result); - } - - @Test - void averageAboveMaximum() { - Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), Long.MAX_VALUE, Duration.ZERO); - Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(4)), result); - } - - @Test - void noAverage() { - Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), null, Duration.ZERO); - Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result); - } - } - } - } - - @Nested - class FeedNotModified { - - @Nested - class EmpiricalDisabled { - @ParameterizedTest - @ValueSource(longs = { 0, 1, 300, 86400000L }) - void withoutValidFor(long averageEntryInterval) { - // averageEntryInterval is ignored when empirical is disabled - Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), averageEntryInterval); - Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result); - } - } - - @Nested - class EmpiricalEnabled { - @BeforeEach - void setUp() { - Mockito.when(config.feedRefresh().intervalEmpirical()).thenReturn(true); - calculator = new FeedRefreshIntervalCalculator(config, instantSource); - } - - @Test - void withNullPublishedDate() { - Instant result = calculator.onFeedNotModified(null, 1L); - Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result); - } - - @Test - void with31DaysOldPublishedDate() { - Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(31)), 1L); - Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result); - } - - @Test - void with15DaysOldPublishedDate() { - Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(15)), 1L); - Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(2)), result); - } - - @Test - void with8DaysOldPublishedDate() { - Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(8)), 1L); - Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(4)), result); - } - - @Nested - class FiveDaysOld { - @Test - void averageBetweenBounds() { - Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), Duration.ofHours(4).toMillis()); - Assertions.assertEquals(NOW.plus(Duration.ofHours(2)), result); - } - - @Test - void averageBelowMinimum() { - Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), 10L); - Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result); - } - - @Test - void averageAboveMaximum() { - Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), Long.MAX_VALUE); - Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(4)), result); - } - - @Test - void noAverage() { - Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), null); - Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result); - } - } - } - } - - @Nested - class FetchError { - @BeforeEach - void setUp() { - Mockito.when(config.feedRefresh().errors().retriesBeforeBackoff()).thenReturn(3); - } - - @Test - void lowErrorCount() { - Instant result = calculator.onFetchError(1); - Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result); - } - - @Test - void highErrorCount() { - Mockito.when(config.feedRefresh().errors().backoffInterval()).thenReturn(Duration.ofHours(1)); - - Instant result = calculator.onFetchError(5); - Assertions.assertEquals(NOW.plus(Duration.ofHours(3)), result); - } - - @Test - void veryHighErrorCount() { - Mockito.when(config.feedRefresh().errors().backoffInterval()).thenReturn(Duration.ofHours(1)); - - Instant result = calculator.onFetchError(100000); - Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result); - } - } - - @Nested - class TooManyRequests { - - @BeforeEach - void setUp() { - Mockito.when(config.feedRefresh().errors().retriesBeforeBackoff()).thenReturn(3); - } - - @Test - void withRetryAfterZero() { - Instant result = calculator.onTooManyRequests(NOW, 1); - Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result); - } - - @Test - void withRetryAfterLowerThanInterval() { - Instant retryAfter = NOW.plus(DEFAULT_INTERVAL.minusSeconds(10)); - Instant result = calculator.onTooManyRequests(retryAfter, 1); - Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result); - } - - @Test - void withRetryAfterBetweenBounds() { - Instant retryAfter = NOW.plus(DEFAULT_INTERVAL.plusSeconds(10)); - Instant result = calculator.onTooManyRequests(retryAfter, 1); - Assertions.assertEquals(retryAfter, result); - } - - @Test - void withRetryAfterGreaterThanMaxInterval() { - Instant retryAfter = NOW.plus(MAX_INTERVAL.plusSeconds(10)); - Instant result = calculator.onTooManyRequests(retryAfter, 1); - Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result); - } - } +package com.commafeed.backend.feed; + +import java.time.Duration; +import java.time.Instant; +import java.time.InstantSource; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.CommaFeedConfiguration.FeedRefreshErrorHandling; + +@ExtendWith(MockitoExtension.class) +class FeedRefreshIntervalCalculatorTest { + + private static final Instant NOW = Instant.now(); + private static final Duration DEFAULT_INTERVAL = Duration.ofHours(1); + private static final Duration MAX_INTERVAL = Duration.ofDays(1); + + @Mock + private InstantSource instantSource; + + @Mock + private CommaFeedConfiguration config; + + @Mock + private FeedRefreshErrorHandling errorHandling; + + private FeedRefreshIntervalCalculator calculator; + + @BeforeEach + void setUp() { + Mockito.when(instantSource.instant()).thenReturn(NOW); + Mockito.when(config.feedRefresh()).thenReturn(Mockito.mock(CommaFeedConfiguration.FeedRefresh.class)); + Mockito.when(config.feedRefresh().interval()).thenReturn(DEFAULT_INTERVAL); + Mockito.when(config.feedRefresh().maxInterval()).thenReturn(MAX_INTERVAL); + Mockito.when(config.feedRefresh().errors()).thenReturn(errorHandling); + + calculator = new FeedRefreshIntervalCalculator(config, instantSource); + } + + @Nested + class FetchSuccess { + + @Nested + class EmpiricalDisabled { + @ParameterizedTest + @ValueSource(longs = { 0, 1, 300, 86400000L }) + void withoutValidFor(long averageEntryInterval) { + // averageEntryInterval is ignored when empirical is disabled + Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), averageEntryInterval, Duration.ZERO); + Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result); + } + + @Test + void withValidForGreaterThanMaxInterval() { + Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), 1L, MAX_INTERVAL.plusDays(1)); + Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result); + } + + @Test + void withValidForLowerThanMaxInterval() { + Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), 1L, MAX_INTERVAL.minusSeconds(1)); + Assertions.assertEquals(NOW.plus(MAX_INTERVAL).minusSeconds(1), result); + } + } + + @Nested + class EmpiricalEnabled { + @BeforeEach + void setUp() { + Mockito.when(config.feedRefresh().intervalEmpirical()).thenReturn(true); + calculator = new FeedRefreshIntervalCalculator(config, instantSource); + } + + @Test + void withNullPublishedDate() { + Instant result = calculator.onFetchSuccess(null, 1L, Duration.ZERO); + Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result); + } + + @Test + void with31DaysOldPublishedDate() { + Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(31)), 1L, Duration.ZERO); + Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result); + } + + @Test + void with15DaysOldPublishedDate() { + Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(15)), 1L, Duration.ZERO); + Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(2)), result); + } + + @Test + void with8DaysOldPublishedDate() { + Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(8)), 1L, Duration.ZERO); + Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(4)), result); + } + + @Nested + class FiveDaysOld { + @Test + void averageBetweenBounds() { + Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), Duration.ofHours(4).toMillis(), + Duration.ZERO); + Assertions.assertEquals(NOW.plus(Duration.ofHours(2)), result); + } + + @Test + void averageBelowMinimum() { + Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), 10L, Duration.ZERO); + Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result); + } + + @Test + void averageAboveMaximum() { + Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), Long.MAX_VALUE, Duration.ZERO); + Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(4)), result); + } + + @Test + void noAverage() { + Instant result = calculator.onFetchSuccess(NOW.minus(Duration.ofDays(5)), null, Duration.ZERO); + Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result); + } + } + } + } + + @Nested + class FeedNotModified { + + @Nested + class EmpiricalDisabled { + @ParameterizedTest + @ValueSource(longs = { 0, 1, 300, 86400000L }) + void withoutValidFor(long averageEntryInterval) { + // averageEntryInterval is ignored when empirical is disabled + Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), averageEntryInterval); + Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result); + } + } + + @Nested + class EmpiricalEnabled { + @BeforeEach + void setUp() { + Mockito.when(config.feedRefresh().intervalEmpirical()).thenReturn(true); + calculator = new FeedRefreshIntervalCalculator(config, instantSource); + } + + @Test + void withNullPublishedDate() { + Instant result = calculator.onFeedNotModified(null, 1L); + Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result); + } + + @Test + void with31DaysOldPublishedDate() { + Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(31)), 1L); + Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result); + } + + @Test + void with15DaysOldPublishedDate() { + Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(15)), 1L); + Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(2)), result); + } + + @Test + void with8DaysOldPublishedDate() { + Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(8)), 1L); + Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(4)), result); + } + + @Nested + class FiveDaysOld { + @Test + void averageBetweenBounds() { + Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), Duration.ofHours(4).toMillis()); + Assertions.assertEquals(NOW.plus(Duration.ofHours(2)), result); + } + + @Test + void averageBelowMinimum() { + Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), 10L); + Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result); + } + + @Test + void averageAboveMaximum() { + Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), Long.MAX_VALUE); + Assertions.assertEquals(NOW.plus(MAX_INTERVAL.dividedBy(4)), result); + } + + @Test + void noAverage() { + Instant result = calculator.onFeedNotModified(NOW.minus(Duration.ofDays(5)), null); + Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result); + } + } + } + } + + @Nested + class FetchError { + @BeforeEach + void setUp() { + Mockito.when(config.feedRefresh().errors().retriesBeforeBackoff()).thenReturn(3); + } + + @Test + void lowErrorCount() { + Instant result = calculator.onFetchError(1); + Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result); + } + + @Test + void highErrorCount() { + Mockito.when(config.feedRefresh().errors().backoffInterval()).thenReturn(Duration.ofHours(1)); + + Instant result = calculator.onFetchError(5); + Assertions.assertEquals(NOW.plus(Duration.ofHours(3)), result); + } + + @Test + void veryHighErrorCount() { + Mockito.when(config.feedRefresh().errors().backoffInterval()).thenReturn(Duration.ofHours(1)); + + Instant result = calculator.onFetchError(100000); + Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result); + } + } + + @Nested + class TooManyRequests { + + @BeforeEach + void setUp() { + Mockito.when(config.feedRefresh().errors().retriesBeforeBackoff()).thenReturn(3); + } + + @Test + void withRetryAfterZero() { + Instant result = calculator.onTooManyRequests(NOW, 1); + Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result); + } + + @Test + void withRetryAfterLowerThanInterval() { + Instant retryAfter = NOW.plus(DEFAULT_INTERVAL.minusSeconds(10)); + Instant result = calculator.onTooManyRequests(retryAfter, 1); + Assertions.assertEquals(NOW.plus(DEFAULT_INTERVAL), result); + } + + @Test + void withRetryAfterBetweenBounds() { + Instant retryAfter = NOW.plus(DEFAULT_INTERVAL.plusSeconds(10)); + Instant result = calculator.onTooManyRequests(retryAfter, 1); + Assertions.assertEquals(retryAfter, result); + } + + @Test + void withRetryAfterGreaterThanMaxInterval() { + Instant retryAfter = NOW.plus(MAX_INTERVAL.plusSeconds(10)); + Instant result = calculator.onTooManyRequests(retryAfter, 1); + Assertions.assertEquals(NOW.plus(MAX_INTERVAL), result); + } + } } \ No newline at end of file diff --git a/commafeed-server/src/test/java/com/commafeed/backend/feed/FeedUtilsTest.java b/commafeed-server/src/test/java/com/commafeed/backend/feed/FeedUtilsTest.java index e4e3ef80..96dd2fb9 100644 --- a/commafeed-server/src/test/java/com/commafeed/backend/feed/FeedUtilsTest.java +++ b/commafeed-server/src/test/java/com/commafeed/backend/feed/FeedUtilsTest.java @@ -1,81 +1,81 @@ -package com.commafeed.backend.feed; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -class FeedUtilsTest { - - @Test - void testNormalization() { - String urla1 = "http://example.com/hello?a=1&b=2"; - String urla2 = "http://www.example.com/hello?a=1&b=2"; - String urla3 = "http://EXAmPLe.com/HELLo?a=1&b=2"; - String urla4 = "http://example.com/hello?b=2&a=1"; - String urla5 = "https://example.com/hello?a=1&b=2"; - - String urlb1 = "http://ftr.fivefilters.org/makefulltextfeed.php?url=http%3A%2F%2Ffeeds.howtogeek.com%2FHowToGeek&max=10&summary=1"; - String urlb2 = "http://ftr.fivefilters.org/makefulltextfeed.php?url=http://feeds.howtogeek.com/HowToGeek&max=10&summary=1"; - - String urlc1 = "http://feeds.feedburner.com/Frandroid"; - String urlc2 = "http://feeds2.feedburner.com/frandroid"; - String urlc3 = "http://feedproxy.google.com/frandroid"; - String urlc4 = "http://feeds.feedburner.com/Frandroid/"; - String urlc5 = "http://feeds.feedburner.com/Frandroid?format=rss"; - - String urld1 = "http://fivefilters.org/content-only/makefulltextfeed.php?url=http://feeds.feedburner.com/Frandroid"; - String urld2 = "http://fivefilters.org/content-only/makefulltextfeed.php?url=http://feeds2.feedburner.com/Frandroid"; - - Assertions.assertEquals(FeedUtils.normalizeURL(urla1), FeedUtils.normalizeURL(urla2)); - Assertions.assertEquals(FeedUtils.normalizeURL(urla1), FeedUtils.normalizeURL(urla3)); - Assertions.assertEquals(FeedUtils.normalizeURL(urla1), FeedUtils.normalizeURL(urla4)); - Assertions.assertEquals(FeedUtils.normalizeURL(urla1), FeedUtils.normalizeURL(urla5)); - - Assertions.assertEquals(FeedUtils.normalizeURL(urlb1), FeedUtils.normalizeURL(urlb2)); - - Assertions.assertEquals(FeedUtils.normalizeURL(urlc1), FeedUtils.normalizeURL(urlc2)); - Assertions.assertEquals(FeedUtils.normalizeURL(urlc1), FeedUtils.normalizeURL(urlc3)); - Assertions.assertEquals(FeedUtils.normalizeURL(urlc1), FeedUtils.normalizeURL(urlc4)); - Assertions.assertEquals(FeedUtils.normalizeURL(urlc1), FeedUtils.normalizeURL(urlc5)); - - Assertions.assertNotEquals(FeedUtils.normalizeURL(urld1), FeedUtils.normalizeURL(urld2)); - - } - - @Test - void testToAbsoluteUrl() { - String expected = "http://a.com/blog/entry/1"; - - // usual cases - Assertions.assertEquals(expected, FeedUtils.toAbsoluteUrl("http://a.com/blog/entry/1", "http://a.com/feed/", "http://a.com/feed/")); - Assertions.assertEquals(expected, FeedUtils.toAbsoluteUrl("http://a.com/blog/entry/1", "http://a.com/feed", "http://a.com/feed")); - - // relative links - Assertions.assertEquals(expected, FeedUtils.toAbsoluteUrl("../blog/entry/1", "http://a.com/feed/", "http://a.com/feed/")); - Assertions.assertEquals(expected, FeedUtils.toAbsoluteUrl("../blog/entry/1", "feed.xml", "http://a.com/feed/feed.xml")); - - // root-relative links - Assertions.assertEquals(expected, FeedUtils.toAbsoluteUrl("/blog/entry/1", "/feed", "http://a.com/feed")); - - // real cases - Assertions.assertEquals("https://github.com/erusev/parsedown/releases/tag/1.3.0", FeedUtils.toAbsoluteUrl( - "/erusev/parsedown/releases/tag/1.3.0", "/erusev/parsedown/releases", "https://github.com/erusev/parsedown/tags.atom")); - Assertions.assertEquals("http://ergoemacs.org/emacs/elisp_all_about_lines.html", - FeedUtils.toAbsoluteUrl("elisp_all_about_lines.html", "blog.xml", "http://ergoemacs.org/emacs/blog.xml")); - - } - - @Test - void testRemoveTrailingSlash() { - final String url = "http://localhost/"; - final String result = FeedUtils.removeTrailingSlash(url); - Assertions.assertEquals("http://localhost", result); - } - - @Test - void testRemoveTrailingSlashLastSlashOnly() { - final String url = "http://localhost//"; - final String result = FeedUtils.removeTrailingSlash(url); - Assertions.assertEquals("http://localhost/", result); - } - -} +package com.commafeed.backend.feed; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class FeedUtilsTest { + + @Test + void testNormalization() { + String urla1 = "http://example.com/hello?a=1&b=2"; + String urla2 = "http://www.example.com/hello?a=1&b=2"; + String urla3 = "http://EXAmPLe.com/HELLo?a=1&b=2"; + String urla4 = "http://example.com/hello?b=2&a=1"; + String urla5 = "https://example.com/hello?a=1&b=2"; + + String urlb1 = "http://ftr.fivefilters.org/makefulltextfeed.php?url=http%3A%2F%2Ffeeds.howtogeek.com%2FHowToGeek&max=10&summary=1"; + String urlb2 = "http://ftr.fivefilters.org/makefulltextfeed.php?url=http://feeds.howtogeek.com/HowToGeek&max=10&summary=1"; + + String urlc1 = "http://feeds.feedburner.com/Frandroid"; + String urlc2 = "http://feeds2.feedburner.com/frandroid"; + String urlc3 = "http://feedproxy.google.com/frandroid"; + String urlc4 = "http://feeds.feedburner.com/Frandroid/"; + String urlc5 = "http://feeds.feedburner.com/Frandroid?format=rss"; + + String urld1 = "http://fivefilters.org/content-only/makefulltextfeed.php?url=http://feeds.feedburner.com/Frandroid"; + String urld2 = "http://fivefilters.org/content-only/makefulltextfeed.php?url=http://feeds2.feedburner.com/Frandroid"; + + Assertions.assertEquals(FeedUtils.normalizeURL(urla1), FeedUtils.normalizeURL(urla2)); + Assertions.assertEquals(FeedUtils.normalizeURL(urla1), FeedUtils.normalizeURL(urla3)); + Assertions.assertEquals(FeedUtils.normalizeURL(urla1), FeedUtils.normalizeURL(urla4)); + Assertions.assertEquals(FeedUtils.normalizeURL(urla1), FeedUtils.normalizeURL(urla5)); + + Assertions.assertEquals(FeedUtils.normalizeURL(urlb1), FeedUtils.normalizeURL(urlb2)); + + Assertions.assertEquals(FeedUtils.normalizeURL(urlc1), FeedUtils.normalizeURL(urlc2)); + Assertions.assertEquals(FeedUtils.normalizeURL(urlc1), FeedUtils.normalizeURL(urlc3)); + Assertions.assertEquals(FeedUtils.normalizeURL(urlc1), FeedUtils.normalizeURL(urlc4)); + Assertions.assertEquals(FeedUtils.normalizeURL(urlc1), FeedUtils.normalizeURL(urlc5)); + + Assertions.assertNotEquals(FeedUtils.normalizeURL(urld1), FeedUtils.normalizeURL(urld2)); + + } + + @Test + void testToAbsoluteUrl() { + String expected = "http://a.com/blog/entry/1"; + + // usual cases + Assertions.assertEquals(expected, FeedUtils.toAbsoluteUrl("http://a.com/blog/entry/1", "http://a.com/feed/", "http://a.com/feed/")); + Assertions.assertEquals(expected, FeedUtils.toAbsoluteUrl("http://a.com/blog/entry/1", "http://a.com/feed", "http://a.com/feed")); + + // relative links + Assertions.assertEquals(expected, FeedUtils.toAbsoluteUrl("../blog/entry/1", "http://a.com/feed/", "http://a.com/feed/")); + Assertions.assertEquals(expected, FeedUtils.toAbsoluteUrl("../blog/entry/1", "feed.xml", "http://a.com/feed/feed.xml")); + + // root-relative links + Assertions.assertEquals(expected, FeedUtils.toAbsoluteUrl("/blog/entry/1", "/feed", "http://a.com/feed")); + + // real cases + Assertions.assertEquals("https://github.com/erusev/parsedown/releases/tag/1.3.0", FeedUtils.toAbsoluteUrl( + "/erusev/parsedown/releases/tag/1.3.0", "/erusev/parsedown/releases", "https://github.com/erusev/parsedown/tags.atom")); + Assertions.assertEquals("http://ergoemacs.org/emacs/elisp_all_about_lines.html", + FeedUtils.toAbsoluteUrl("elisp_all_about_lines.html", "blog.xml", "http://ergoemacs.org/emacs/blog.xml")); + + } + + @Test + void testRemoveTrailingSlash() { + final String url = "http://localhost/"; + final String result = FeedUtils.removeTrailingSlash(url); + Assertions.assertEquals("http://localhost", result); + } + + @Test + void testRemoveTrailingSlashLastSlashOnly() { + final String url = "http://localhost//"; + final String result = FeedUtils.removeTrailingSlash(url); + Assertions.assertEquals("http://localhost/", result); + } + +} diff --git a/commafeed-server/src/test/java/com/commafeed/backend/feed/parser/EncodingDetectorTest.java b/commafeed-server/src/test/java/com/commafeed/backend/feed/parser/EncodingDetectorTest.java index cf88d11f..44f5b351 100644 --- a/commafeed-server/src/test/java/com/commafeed/backend/feed/parser/EncodingDetectorTest.java +++ b/commafeed-server/src/test/java/com/commafeed/backend/feed/parser/EncodingDetectorTest.java @@ -1,19 +1,19 @@ -package com.commafeed.backend.feed.parser; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -class EncodingDetectorTest { - - EncodingDetector encodingDetector = new EncodingDetector(); - - @Test - void testExtractDeclaredEncoding() { - Assertions.assertNull(encodingDetector.extractDeclaredEncoding("".getBytes())); - Assertions.assertNull(encodingDetector.extractDeclaredEncoding("".getBytes())); - Assertions.assertEquals("UTF-8", encodingDetector.extractDeclaredEncoding("".getBytes())); - Assertions.assertEquals("UTF-8", encodingDetector.extractDeclaredEncoding("".getBytes())); - Assertions.assertEquals("UTF-8", encodingDetector.extractDeclaredEncoding("".getBytes())); - } - +package com.commafeed.backend.feed.parser; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class EncodingDetectorTest { + + EncodingDetector encodingDetector = new EncodingDetector(); + + @Test + void testExtractDeclaredEncoding() { + Assertions.assertNull(encodingDetector.extractDeclaredEncoding("".getBytes())); + Assertions.assertNull(encodingDetector.extractDeclaredEncoding("".getBytes())); + Assertions.assertEquals("UTF-8", encodingDetector.extractDeclaredEncoding("".getBytes())); + Assertions.assertEquals("UTF-8", encodingDetector.extractDeclaredEncoding("".getBytes())); + Assertions.assertEquals("UTF-8", encodingDetector.extractDeclaredEncoding("".getBytes())); + } + } \ No newline at end of file diff --git a/commafeed-server/src/test/java/com/commafeed/backend/feed/parser/FeedCleanerTest.java b/commafeed-server/src/test/java/com/commafeed/backend/feed/parser/FeedCleanerTest.java index c324b958..be575068 100644 --- a/commafeed-server/src/test/java/com/commafeed/backend/feed/parser/FeedCleanerTest.java +++ b/commafeed-server/src/test/java/com/commafeed/backend/feed/parser/FeedCleanerTest.java @@ -1,34 +1,34 @@ -package com.commafeed.backend.feed.parser; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -class FeedCleanerTest { - - FeedCleaner feedCleaner = new FeedCleaner(); - - @Test - void testReplaceHtmlEntitiesWithNumericEntities() { - String source = "T´l´phone ′"; - Assertions.assertEquals("T´l´phone ′", feedCleaner.replaceHtmlEntitiesWithNumericEntities(source)); - } - - @Test - void testRemoveDoctype() { - String source = ""; - Assertions.assertEquals("", feedCleaner.removeDoctypeDeclarations(source)); - } - - @Test - void testRemoveMultilineDoctype() { - String source = """ - - """; - Assertions.assertEquals(""" - - """, feedCleaner.removeDoctypeDeclarations(source)); - } - +package com.commafeed.backend.feed.parser; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class FeedCleanerTest { + + FeedCleaner feedCleaner = new FeedCleaner(); + + @Test + void testReplaceHtmlEntitiesWithNumericEntities() { + String source = "T´l´phone ′"; + Assertions.assertEquals("T´l´phone ′", feedCleaner.replaceHtmlEntitiesWithNumericEntities(source)); + } + + @Test + void testRemoveDoctype() { + String source = ""; + Assertions.assertEquals("", feedCleaner.removeDoctypeDeclarations(source)); + } + + @Test + void testRemoveMultilineDoctype() { + String source = """ + + """; + Assertions.assertEquals(""" + + """, feedCleaner.removeDoctypeDeclarations(source)); + } + } \ No newline at end of file diff --git a/commafeed-server/src/test/java/com/commafeed/backend/feed/parser/TextDirectionDetectorTest.java b/commafeed-server/src/test/java/com/commafeed/backend/feed/parser/TextDirectionDetectorTest.java index 8e27a06f..35ace88b 100644 --- a/commafeed-server/src/test/java/com/commafeed/backend/feed/parser/TextDirectionDetectorTest.java +++ b/commafeed-server/src/test/java/com/commafeed/backend/feed/parser/TextDirectionDetectorTest.java @@ -1,53 +1,53 @@ -package com.commafeed.backend.feed.parser; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -class TextDirectionDetectorTest { - - @Test - public void testEstimateDirection() { - Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect("")); - Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect(" ")); - Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect("! (...)")); - Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect("Pure Ascii content")); - Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect("-17.0%")); - Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect("http://foo/bar/")); - Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, - TextDirectionDetector.detect("http://foo/bar/?s=\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0" - + "\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0" + "\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0")); - Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, TextDirectionDetector.detect("\u05d0")); - Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, TextDirectionDetector.detect("\u05d0")); - Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, - TextDirectionDetector.detect("http://foo/bar/ \u05d0 http://foo2/bar2/ http://foo3/bar3/")); - Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, - TextDirectionDetector.detect("\u05d0\u05d9\u05df \u05de\u05de\u05e9 " + "\u05de\u05d4 \u05dc\u05e8\u05d0\u05d5\u05ea: " - + "\u05dc\u05d0 \u05e6\u05d9\u05dc\u05de\u05ea\u05d9 " + "\u05d4\u05e8\u05d1\u05d4 \u05d5\u05d2\u05dd \u05d0" - + "\u05dd \u05d4\u05d9\u05d9\u05ea\u05d9 \u05de\u05e6\u05dc" + "\u05dd, \u05d4\u05d9\u05d4 \u05e9\u05dd")); - Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, - TextDirectionDetector.detect("\u05db\u05d0\u05df - http://geek.co.il/gallery/v/2007-06" - + " - \u05d0\u05d9\u05df \u05de\u05de\u05e9 \u05de\u05d4 " + "\u05dc\u05e8\u05d0\u05d5\u05ea: \u05dc\u05d0 \u05e6" - + "\u05d9\u05dc\u05de\u05ea\u05d9 \u05d4\u05e8\u05d1\u05d4 " - + "\u05d5\u05d2\u05dd \u05d0\u05dd \u05d4\u05d9\u05d9\u05ea" - + "\u05d9 \u05de\u05e6\u05dc\u05dd, \u05d4\u05d9\u05d4 " - + "\u05e9\u05dd \u05d1\u05e2\u05d9\u05e7\u05e8 \u05d4\u05e8" + "\u05d1\u05d4 \u05d0\u05e0\u05e9\u05d9\u05dd. \u05de" - + "\u05d4 \u05e9\u05db\u05df - \u05d0\u05e4\u05e9\u05e8 " + "\u05dc\u05e0\u05e6\u05dc \u05d0\u05ea \u05d4\u05d4 " - + "\u05d3\u05d6\u05de\u05e0\u05d5\u05ea \u05dc\u05d4\u05e1" + "\u05ea\u05db\u05dc \u05e2\u05dc \u05db\u05de\u05d4 " - + "\u05ea\u05de\u05d5\u05e0\u05d5\u05ea \u05de\u05e9\u05e2" - + "\u05e9\u05e2\u05d5\u05ea \u05d9\u05e9\u05e0\u05d5\u05ea " + "\u05d9\u05d5\u05ea\u05e8 \u05e9\u05d9\u05e9 \u05dc" - + "\u05d9 \u05d1\u05d0\u05ea\u05e8")); - Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, - TextDirectionDetector.detect("CAPTCHA \u05de\u05e9\u05d5\u05db\u05dc\u05dc " + "\u05de\u05d3\u05d9?")); - Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, - TextDirectionDetector.detect("Yes Prime Minister \u05e2\u05d3\u05db\u05d5\u05df. " - + "\u05e9\u05d0\u05dc\u05d5 \u05d0\u05d5\u05ea\u05d9 " + "\u05de\u05d4 \u05d0\u05e0\u05d9 \u05e8\u05d5\u05e6" - + "\u05d4 \u05de\u05ea\u05e0\u05d4 \u05dc\u05d7\u05d2")); - Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, TextDirectionDetector - .detect("17.4.02 \u05e9\u05e2\u05d4:13-20 .15-00 .\u05dc\u05d0 " + "\u05d4\u05d9\u05d9\u05ea\u05d9 \u05db\u05d0\u05df.")); - Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, - TextDirectionDetector.detect("5710 5720 5730. \u05d4\u05d3\u05dc\u05ea. " + "\u05d4\u05e0\u05e9\u05d9\u05e7\u05d4")); - Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, - TextDirectionDetector.detect("\u05d4\u05d3\u05dc\u05ea http://www.google.com " + "http://www.gmail.com")); - } - +package com.commafeed.backend.feed.parser; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class TextDirectionDetectorTest { + + @Test + public void testEstimateDirection() { + Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect("")); + Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect(" ")); + Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect("! (...)")); + Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect("Pure Ascii content")); + Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect("-17.0%")); + Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect("http://foo/bar/")); + Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, + TextDirectionDetector.detect("http://foo/bar/?s=\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0" + + "\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0" + "\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0")); + Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, TextDirectionDetector.detect("\u05d0")); + Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, TextDirectionDetector.detect("\u05d0")); + Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, + TextDirectionDetector.detect("http://foo/bar/ \u05d0 http://foo2/bar2/ http://foo3/bar3/")); + Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, + TextDirectionDetector.detect("\u05d0\u05d9\u05df \u05de\u05de\u05e9 " + "\u05de\u05d4 \u05dc\u05e8\u05d0\u05d5\u05ea: " + + "\u05dc\u05d0 \u05e6\u05d9\u05dc\u05de\u05ea\u05d9 " + "\u05d4\u05e8\u05d1\u05d4 \u05d5\u05d2\u05dd \u05d0" + + "\u05dd \u05d4\u05d9\u05d9\u05ea\u05d9 \u05de\u05e6\u05dc" + "\u05dd, \u05d4\u05d9\u05d4 \u05e9\u05dd")); + Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, + TextDirectionDetector.detect("\u05db\u05d0\u05df - http://geek.co.il/gallery/v/2007-06" + + " - \u05d0\u05d9\u05df \u05de\u05de\u05e9 \u05de\u05d4 " + "\u05dc\u05e8\u05d0\u05d5\u05ea: \u05dc\u05d0 \u05e6" + + "\u05d9\u05dc\u05de\u05ea\u05d9 \u05d4\u05e8\u05d1\u05d4 " + + "\u05d5\u05d2\u05dd \u05d0\u05dd \u05d4\u05d9\u05d9\u05ea" + + "\u05d9 \u05de\u05e6\u05dc\u05dd, \u05d4\u05d9\u05d4 " + + "\u05e9\u05dd \u05d1\u05e2\u05d9\u05e7\u05e8 \u05d4\u05e8" + "\u05d1\u05d4 \u05d0\u05e0\u05e9\u05d9\u05dd. \u05de" + + "\u05d4 \u05e9\u05db\u05df - \u05d0\u05e4\u05e9\u05e8 " + "\u05dc\u05e0\u05e6\u05dc \u05d0\u05ea \u05d4\u05d4 " + + "\u05d3\u05d6\u05de\u05e0\u05d5\u05ea \u05dc\u05d4\u05e1" + "\u05ea\u05db\u05dc \u05e2\u05dc \u05db\u05de\u05d4 " + + "\u05ea\u05de\u05d5\u05e0\u05d5\u05ea \u05de\u05e9\u05e2" + + "\u05e9\u05e2\u05d5\u05ea \u05d9\u05e9\u05e0\u05d5\u05ea " + "\u05d9\u05d5\u05ea\u05e8 \u05e9\u05d9\u05e9 \u05dc" + + "\u05d9 \u05d1\u05d0\u05ea\u05e8")); + Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, + TextDirectionDetector.detect("CAPTCHA \u05de\u05e9\u05d5\u05db\u05dc\u05dc " + "\u05de\u05d3\u05d9?")); + Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, + TextDirectionDetector.detect("Yes Prime Minister \u05e2\u05d3\u05db\u05d5\u05df. " + + "\u05e9\u05d0\u05dc\u05d5 \u05d0\u05d5\u05ea\u05d9 " + "\u05de\u05d4 \u05d0\u05e0\u05d9 \u05e8\u05d5\u05e6" + + "\u05d4 \u05de\u05ea\u05e0\u05d4 \u05dc\u05d7\u05d2")); + Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, TextDirectionDetector + .detect("17.4.02 \u05e9\u05e2\u05d4:13-20 .15-00 .\u05dc\u05d0 " + "\u05d4\u05d9\u05d9\u05ea\u05d9 \u05db\u05d0\u05df.")); + Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, + TextDirectionDetector.detect("5710 5720 5730. \u05d4\u05d3\u05dc\u05ea. " + "\u05d4\u05e0\u05e9\u05d9\u05e7\u05d4")); + Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, + TextDirectionDetector.detect("\u05d4\u05d3\u05dc\u05ea http://www.google.com " + "http://www.gmail.com")); + } + } \ No newline at end of file diff --git a/commafeed-server/src/test/java/com/commafeed/backend/opml/OPMLExporterTest.java b/commafeed-server/src/test/java/com/commafeed/backend/opml/OPMLExporterTest.java index 13eba78e..11c30799 100644 --- a/commafeed-server/src/test/java/com/commafeed/backend/opml/OPMLExporterTest.java +++ b/commafeed-server/src/test/java/com/commafeed/backend/opml/OPMLExporterTest.java @@ -1,147 +1,147 @@ -package com.commafeed.backend.opml; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.junit.jupiter.MockitoExtension; - -import com.commafeed.backend.dao.FeedCategoryDAO; -import com.commafeed.backend.dao.FeedSubscriptionDAO; -import com.commafeed.backend.model.Feed; -import com.commafeed.backend.model.FeedCategory; -import com.commafeed.backend.model.FeedSubscription; -import com.commafeed.backend.model.User; -import com.rometools.opml.feed.opml.Opml; -import com.rometools.opml.feed.opml.Outline; - -@ExtendWith(MockitoExtension.class) -class OPMLExporterTest { - - @Mock - private FeedCategoryDAO feedCategoryDAO; - @Mock - private FeedSubscriptionDAO feedSubscriptionDAO; - - private final User user = new User(); - - private final FeedCategory cat1 = new FeedCategory(); - private final FeedCategory cat2 = new FeedCategory(); - - private final FeedSubscription rootFeed = newFeedSubscription("rootFeed", "rootFeed.com"); - private final FeedSubscription cat1Feed = newFeedSubscription("cat1Feed", "cat1Feed.com"); - private final FeedSubscription cat2Feed = newFeedSubscription("cat2Feed", "cat2Feed.com"); - - private final List categories = new ArrayList<>(); - private final List subscriptions = new ArrayList<>(); - - @BeforeEach - public void init() { - user.setName("John Doe"); - - cat1.setId(1L); - cat1.setName("cat1"); - cat1.setParent(null); - cat1.setChildren(new HashSet<>()); - cat1.setSubscriptions(new HashSet<>()); - - cat2.setId(2L); - cat2.setName("cat2"); - cat2.setParent(cat1); - cat2.setChildren(new HashSet<>()); - cat2.setSubscriptions(new HashSet<>()); - - cat1.getChildren().add(cat2); - - rootFeed.setCategory(null); - cat1Feed.setCategory(cat1); - cat2Feed.setCategory(cat2); - - cat1.getSubscriptions().add(cat1Feed); - cat2.getSubscriptions().add(cat2Feed); - - categories.add(cat1); - categories.add(cat2); - - subscriptions.add(rootFeed); - subscriptions.add(cat1Feed); - subscriptions.add(cat2Feed); - } - - private Feed newFeed(String url) { - Feed feed = new Feed(); - feed.setUrl(url); - return feed; - } - - private FeedSubscription newFeedSubscription(String title, String url) { - FeedSubscription feedSubscription = new FeedSubscription(); - feedSubscription.setTitle(title); - feedSubscription.setFeed(newFeed(url)); - return feedSubscription; - } - - @Test - void generatesOpmlCorrectly() { - Mockito.when(feedCategoryDAO.findAll(user)).thenReturn(categories); - Mockito.when(feedSubscriptionDAO.findAll(user)).thenReturn(subscriptions); - - Opml opml = new OPMLExporter(feedCategoryDAO, feedSubscriptionDAO).export(user); - - List rootOutlines = opml.getOutlines(); - Assertions.assertEquals(2, rootOutlines.size()); - Assertions.assertTrue(containsCategory(rootOutlines, "cat1")); - Assertions.assertTrue(containsFeed(rootOutlines, "rootFeed", "rootFeed.com")); - - Outline cat1Outline = getCategoryOutline(rootOutlines, "cat1"); - List cat1Children = cat1Outline.getChildren(); - Assertions.assertEquals(2, cat1Children.size()); - Assertions.assertTrue(containsCategory(cat1Children, "cat2")); - Assertions.assertTrue(containsFeed(cat1Children, "cat1Feed", "cat1Feed.com")); - - Outline cat2Outline = getCategoryOutline(cat1Children, "cat2"); - List cat2Children = cat2Outline.getChildren(); - Assertions.assertEquals(1, cat2Children.size()); - Assertions.assertTrue(containsFeed(cat2Children, "cat2Feed", "cat2Feed.com")); - } - - private boolean containsCategory(List outlines, String category) { - for (Outline o : outlines) { - if (!"rss".equals(o.getType())) { - if (category.equals(o.getTitle())) { - return true; - } - } - } - - return false; - } - - private boolean containsFeed(List outlines, String title, String url) { - for (Outline o : outlines) { - if ("rss".equals(o.getType())) { - if (title.equals(o.getTitle()) && o.getAttributeValue("xmlUrl").equals(url)) { - return true; - } - } - } - - return false; - } - - private Outline getCategoryOutline(List outlines, String title) { - for (Outline o : outlines) { - if (o.getTitle().equals(title)) { - return o; - } - } - - return null; - } +package com.commafeed.backend.opml; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.commafeed.backend.dao.FeedCategoryDAO; +import com.commafeed.backend.dao.FeedSubscriptionDAO; +import com.commafeed.backend.model.Feed; +import com.commafeed.backend.model.FeedCategory; +import com.commafeed.backend.model.FeedSubscription; +import com.commafeed.backend.model.User; +import com.rometools.opml.feed.opml.Opml; +import com.rometools.opml.feed.opml.Outline; + +@ExtendWith(MockitoExtension.class) +class OPMLExporterTest { + + @Mock + private FeedCategoryDAO feedCategoryDAO; + @Mock + private FeedSubscriptionDAO feedSubscriptionDAO; + + private final User user = new User(); + + private final FeedCategory cat1 = new FeedCategory(); + private final FeedCategory cat2 = new FeedCategory(); + + private final FeedSubscription rootFeed = newFeedSubscription("rootFeed", "rootFeed.com"); + private final FeedSubscription cat1Feed = newFeedSubscription("cat1Feed", "cat1Feed.com"); + private final FeedSubscription cat2Feed = newFeedSubscription("cat2Feed", "cat2Feed.com"); + + private final List categories = new ArrayList<>(); + private final List subscriptions = new ArrayList<>(); + + @BeforeEach + public void init() { + user.setName("John Doe"); + + cat1.setId(1L); + cat1.setName("cat1"); + cat1.setParent(null); + cat1.setChildren(new HashSet<>()); + cat1.setSubscriptions(new HashSet<>()); + + cat2.setId(2L); + cat2.setName("cat2"); + cat2.setParent(cat1); + cat2.setChildren(new HashSet<>()); + cat2.setSubscriptions(new HashSet<>()); + + cat1.getChildren().add(cat2); + + rootFeed.setCategory(null); + cat1Feed.setCategory(cat1); + cat2Feed.setCategory(cat2); + + cat1.getSubscriptions().add(cat1Feed); + cat2.getSubscriptions().add(cat2Feed); + + categories.add(cat1); + categories.add(cat2); + + subscriptions.add(rootFeed); + subscriptions.add(cat1Feed); + subscriptions.add(cat2Feed); + } + + private Feed newFeed(String url) { + Feed feed = new Feed(); + feed.setUrl(url); + return feed; + } + + private FeedSubscription newFeedSubscription(String title, String url) { + FeedSubscription feedSubscription = new FeedSubscription(); + feedSubscription.setTitle(title); + feedSubscription.setFeed(newFeed(url)); + return feedSubscription; + } + + @Test + void generatesOpmlCorrectly() { + Mockito.when(feedCategoryDAO.findAll(user)).thenReturn(categories); + Mockito.when(feedSubscriptionDAO.findAll(user)).thenReturn(subscriptions); + + Opml opml = new OPMLExporter(feedCategoryDAO, feedSubscriptionDAO).export(user); + + List rootOutlines = opml.getOutlines(); + Assertions.assertEquals(2, rootOutlines.size()); + Assertions.assertTrue(containsCategory(rootOutlines, "cat1")); + Assertions.assertTrue(containsFeed(rootOutlines, "rootFeed", "rootFeed.com")); + + Outline cat1Outline = getCategoryOutline(rootOutlines, "cat1"); + List cat1Children = cat1Outline.getChildren(); + Assertions.assertEquals(2, cat1Children.size()); + Assertions.assertTrue(containsCategory(cat1Children, "cat2")); + Assertions.assertTrue(containsFeed(cat1Children, "cat1Feed", "cat1Feed.com")); + + Outline cat2Outline = getCategoryOutline(cat1Children, "cat2"); + List cat2Children = cat2Outline.getChildren(); + Assertions.assertEquals(1, cat2Children.size()); + Assertions.assertTrue(containsFeed(cat2Children, "cat2Feed", "cat2Feed.com")); + } + + private boolean containsCategory(List outlines, String category) { + for (Outline o : outlines) { + if (!"rss".equals(o.getType())) { + if (category.equals(o.getTitle())) { + return true; + } + } + } + + return false; + } + + private boolean containsFeed(List outlines, String title, String url) { + for (Outline o : outlines) { + if ("rss".equals(o.getType())) { + if (title.equals(o.getTitle()) && o.getAttributeValue("xmlUrl").equals(url)) { + return true; + } + } + } + + return false; + } + + private Outline getCategoryOutline(List outlines, String title) { + for (Outline o : outlines) { + if (o.getTitle().equals(title)) { + return o; + } + } + + return null; + } } \ No newline at end of file diff --git a/commafeed-server/src/test/java/com/commafeed/backend/opml/OPMLImporterTest.java b/commafeed-server/src/test/java/com/commafeed/backend/opml/OPMLImporterTest.java index 08cd6f90..1193bc3f 100644 --- a/commafeed-server/src/test/java/com/commafeed/backend/opml/OPMLImporterTest.java +++ b/commafeed-server/src/test/java/com/commafeed/backend/opml/OPMLImporterTest.java @@ -1,52 +1,52 @@ -package com.commafeed.backend.opml; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; - -import org.apache.commons.io.IOUtils; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import com.commafeed.backend.dao.FeedCategoryDAO; -import com.commafeed.backend.model.FeedCategory; -import com.commafeed.backend.model.User; -import com.commafeed.backend.service.FeedSubscriptionService; -import com.rometools.rome.io.FeedException; - -class OPMLImporterTest { - - @Test - void testOpmlV10() throws IOException, IllegalArgumentException, FeedException { - testOpmlVersion("/opml/opml_v1.0.xml"); - } - - @Test - void testOpmlV11() throws IOException, IllegalArgumentException, FeedException { - testOpmlVersion("/opml/opml_v1.1.xml"); - } - - @Test - void testOpmlV20() throws IOException, IllegalArgumentException, FeedException { - testOpmlVersion("/opml/opml_v2.0.xml"); - } - - @Test - void testOpmlNoVersion() throws IOException, IllegalArgumentException, FeedException { - testOpmlVersion("/opml/opml_noversion.xml"); - } - - private void testOpmlVersion(String fileName) throws IOException, IllegalArgumentException, FeedException { - FeedCategoryDAO feedCategoryDAO = Mockito.mock(FeedCategoryDAO.class); - FeedSubscriptionService feedSubscriptionService = Mockito.mock(FeedSubscriptionService.class); - User user = Mockito.mock(User.class); - - String xml = IOUtils.toString(getClass().getResourceAsStream(fileName), StandardCharsets.UTF_8); - - OPMLImporter importer = new OPMLImporter(feedCategoryDAO, feedSubscriptionService); - importer.importOpml(user, xml); - - Mockito.verify(feedSubscriptionService) - .subscribe(Mockito.eq(user), Mockito.anyString(), Mockito.anyString(), Mockito.any(FeedCategory.class), Mockito.anyInt()); - } - -} +package com.commafeed.backend.opml; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import com.commafeed.backend.dao.FeedCategoryDAO; +import com.commafeed.backend.model.FeedCategory; +import com.commafeed.backend.model.User; +import com.commafeed.backend.service.FeedSubscriptionService; +import com.rometools.rome.io.FeedException; + +class OPMLImporterTest { + + @Test + void testOpmlV10() throws IOException, IllegalArgumentException, FeedException { + testOpmlVersion("/opml/opml_v1.0.xml"); + } + + @Test + void testOpmlV11() throws IOException, IllegalArgumentException, FeedException { + testOpmlVersion("/opml/opml_v1.1.xml"); + } + + @Test + void testOpmlV20() throws IOException, IllegalArgumentException, FeedException { + testOpmlVersion("/opml/opml_v2.0.xml"); + } + + @Test + void testOpmlNoVersion() throws IOException, IllegalArgumentException, FeedException { + testOpmlVersion("/opml/opml_noversion.xml"); + } + + private void testOpmlVersion(String fileName) throws IOException, IllegalArgumentException, FeedException { + FeedCategoryDAO feedCategoryDAO = Mockito.mock(FeedCategoryDAO.class); + FeedSubscriptionService feedSubscriptionService = Mockito.mock(FeedSubscriptionService.class); + User user = Mockito.mock(User.class); + + String xml = IOUtils.toString(getClass().getResourceAsStream(fileName), StandardCharsets.UTF_8); + + OPMLImporter importer = new OPMLImporter(feedCategoryDAO, feedSubscriptionService); + importer.importOpml(user, xml); + + Mockito.verify(feedSubscriptionService) + .subscribe(Mockito.eq(user), Mockito.anyString(), Mockito.anyString(), Mockito.any(FeedCategory.class), Mockito.anyInt()); + } + +} diff --git a/commafeed-server/src/test/java/com/commafeed/backend/service/FeedEntryContentCleaningServiceTest.java b/commafeed-server/src/test/java/com/commafeed/backend/service/FeedEntryContentCleaningServiceTest.java index 3a23b95b..a5600962 100644 --- a/commafeed-server/src/test/java/com/commafeed/backend/service/FeedEntryContentCleaningServiceTest.java +++ b/commafeed-server/src/test/java/com/commafeed/backend/service/FeedEntryContentCleaningServiceTest.java @@ -1,31 +1,31 @@ -package com.commafeed.backend.service; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -class FeedEntryContentCleaningServiceTest { - - private final FeedEntryContentCleaningService feedEntryContentCleaningService = new FeedEntryContentCleaningService(); - - @Test - void testClean() { - String content = """ -

- Some text - alt-desc - - aaa - """; - String result = feedEntryContentCleaningService.clean(content, "baseUri", false); - - Assertions.assertLinesMatch(""" -

- Some text - alt-desc - - aaa -

- """.lines(), result.lines()); - } - +package com.commafeed.backend.service; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class FeedEntryContentCleaningServiceTest { + + private final FeedEntryContentCleaningService feedEntryContentCleaningService = new FeedEntryContentCleaningService(); + + @Test + void testClean() { + String content = """ +

+ Some text + alt-desc + + aaa + """; + String result = feedEntryContentCleaningService.clean(content, "baseUri", false); + + Assertions.assertLinesMatch(""" +

+ Some text + alt-desc + + aaa +

+ """.lines(), result.lines()); + } + } \ No newline at end of file diff --git a/commafeed-server/src/test/java/com/commafeed/backend/service/FeedEntryFilteringServiceTest.java b/commafeed-server/src/test/java/com/commafeed/backend/service/FeedEntryFilteringServiceTest.java index 49db98b6..4ab2b6b1 100644 --- a/commafeed-server/src/test/java/com/commafeed/backend/service/FeedEntryFilteringServiceTest.java +++ b/commafeed-server/src/test/java/com/commafeed/backend/service/FeedEntryFilteringServiceTest.java @@ -1,96 +1,96 @@ -package com.commafeed.backend.service; - -import java.time.Duration; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.model.FeedEntry; -import com.commafeed.backend.model.FeedEntryContent; -import com.commafeed.backend.service.FeedEntryFilteringService.FeedEntryFilterException; - -class FeedEntryFilteringServiceTest { - private CommaFeedConfiguration config; - - private FeedEntryFilteringService service; - - private FeedEntry entry; - - @BeforeEach - public void init() { - config = Mockito.mock(CommaFeedConfiguration.class, Mockito.RETURNS_DEEP_STUBS); - Mockito.when(config.feedRefresh().filteringExpressionEvaluationTimeout()).thenReturn(Duration.ofSeconds(30)); - - service = new FeedEntryFilteringService(config); - - entry = new FeedEntry(); - entry.setUrl("https://github.com/Athou/commafeed"); - - FeedEntryContent content = new FeedEntryContent(); - content.setAuthor("Athou"); - content.setTitle("Merge pull request #662 from Athou/dw8"); - content.setContent("Merge pull request #662 from Athou/dw8"); - entry.setContent(content); - - } - - @Test - void emptyFilterMatchesFilter() throws FeedEntryFilterException { - Assertions.assertTrue(service.filterMatchesEntry(null, entry)); - } - - @Test - void blankFilterMatchesFilter() throws FeedEntryFilterException { - Assertions.assertTrue(service.filterMatchesEntry("", entry)); - } - - @Test - void simpleExpression() throws FeedEntryFilterException { - Assertions.assertTrue(service.filterMatchesEntry("author.toString() eq 'athou'", entry)); - } - - @Test - void newIsDisabled() { - Assertions.assertThrows(FeedEntryFilterException.class, - () -> service.filterMatchesEntry("null eq new ('java.lang.String', 'athou')", entry)); - } - - @Test - void getClassMethodIsDisabled() { - Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("null eq ''.getClass()", entry)); - } - - @Test - void dotClassIsDisabled() throws FeedEntryFilterException { - Assertions.assertTrue(service.filterMatchesEntry("null eq ''.class", entry)); - } - - @Test - void cannotLoopForever() { - Mockito.when(config.feedRefresh().filteringExpressionEvaluationTimeout()).thenReturn(Duration.ofMillis(200)); - service = new FeedEntryFilteringService(config); - - Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("while(true) {}", entry)); - } - - @Test - void handlesNullCorrectly() { - entry.setUrl(null); - entry.setContent(new FeedEntryContent()); - Assertions.assertDoesNotThrow(() -> service.filterMatchesEntry("author eq 'athou'", entry)); - } - - @Test - void incorrectScriptThrowsException() { - Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("aa eqz bb", entry)); - } - - @Test - void incorrectReturnTypeThrowsException() { - Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("1", entry)); - } - -} +package com.commafeed.backend.service; + +import java.time.Duration; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.backend.model.FeedEntry; +import com.commafeed.backend.model.FeedEntryContent; +import com.commafeed.backend.service.FeedEntryFilteringService.FeedEntryFilterException; + +class FeedEntryFilteringServiceTest { + private CommaFeedConfiguration config; + + private FeedEntryFilteringService service; + + private FeedEntry entry; + + @BeforeEach + public void init() { + config = Mockito.mock(CommaFeedConfiguration.class, Mockito.RETURNS_DEEP_STUBS); + Mockito.when(config.feedRefresh().filteringExpressionEvaluationTimeout()).thenReturn(Duration.ofSeconds(30)); + + service = new FeedEntryFilteringService(config); + + entry = new FeedEntry(); + entry.setUrl("https://github.com/Athou/commafeed"); + + FeedEntryContent content = new FeedEntryContent(); + content.setAuthor("Athou"); + content.setTitle("Merge pull request #662 from Athou/dw8"); + content.setContent("Merge pull request #662 from Athou/dw8"); + entry.setContent(content); + + } + + @Test + void emptyFilterMatchesFilter() throws FeedEntryFilterException { + Assertions.assertTrue(service.filterMatchesEntry(null, entry)); + } + + @Test + void blankFilterMatchesFilter() throws FeedEntryFilterException { + Assertions.assertTrue(service.filterMatchesEntry("", entry)); + } + + @Test + void simpleExpression() throws FeedEntryFilterException { + Assertions.assertTrue(service.filterMatchesEntry("author.toString() eq 'athou'", entry)); + } + + @Test + void newIsDisabled() { + Assertions.assertThrows(FeedEntryFilterException.class, + () -> service.filterMatchesEntry("null eq new ('java.lang.String', 'athou')", entry)); + } + + @Test + void getClassMethodIsDisabled() { + Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("null eq ''.getClass()", entry)); + } + + @Test + void dotClassIsDisabled() throws FeedEntryFilterException { + Assertions.assertTrue(service.filterMatchesEntry("null eq ''.class", entry)); + } + + @Test + void cannotLoopForever() { + Mockito.when(config.feedRefresh().filteringExpressionEvaluationTimeout()).thenReturn(Duration.ofMillis(200)); + service = new FeedEntryFilteringService(config); + + Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("while(true) {}", entry)); + } + + @Test + void handlesNullCorrectly() { + entry.setUrl(null); + entry.setContent(new FeedEntryContent()); + Assertions.assertDoesNotThrow(() -> service.filterMatchesEntry("author eq 'athou'", entry)); + } + + @Test + void incorrectScriptThrowsException() { + Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("aa eqz bb", entry)); + } + + @Test + void incorrectReturnTypeThrowsException() { + Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("1", entry)); + } + +} diff --git a/commafeed-server/src/test/java/com/commafeed/backend/service/PasswordEncryptionServiceTest.java b/commafeed-server/src/test/java/com/commafeed/backend/service/PasswordEncryptionServiceTest.java index 15a1b980..afe728d6 100644 --- a/commafeed-server/src/test/java/com/commafeed/backend/service/PasswordEncryptionServiceTest.java +++ b/commafeed-server/src/test/java/com/commafeed/backend/service/PasswordEncryptionServiceTest.java @@ -1,23 +1,23 @@ -package com.commafeed.backend.service; - -import java.util.HexFormat; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -class PasswordEncryptionServiceTest { - - @Test - void authenticate() { - String password = "password"; - byte[] salt = "abcdefgh".getBytes(); - - PasswordEncryptionService passwordEncryptionService = new PasswordEncryptionService(); - byte[] encryptedPassword = passwordEncryptionService.getEncryptedPassword(password, salt); - - // make sure the encrypted password is always the same for a fixed salt - Assertions.assertEquals("8b4660158141d9f4f7865718b9a2b940a3e3cea9", HexFormat.of().formatHex(encryptedPassword)); - Assertions.assertTrue(passwordEncryptionService.authenticate(password, encryptedPassword, salt)); - } - +package com.commafeed.backend.service; + +import java.util.HexFormat; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class PasswordEncryptionServiceTest { + + @Test + void authenticate() { + String password = "password"; + byte[] salt = "abcdefgh".getBytes(); + + PasswordEncryptionService passwordEncryptionService = new PasswordEncryptionService(); + byte[] encryptedPassword = passwordEncryptionService.getEncryptedPassword(password, salt); + + // make sure the encrypted password is always the same for a fixed salt + Assertions.assertEquals("8b4660158141d9f4f7865718b9a2b940a3e3cea9", HexFormat.of().formatHex(encryptedPassword)); + Assertions.assertTrue(passwordEncryptionService.authenticate(password, encryptedPassword, salt)); + } + } \ No newline at end of file diff --git a/commafeed-server/src/test/java/com/commafeed/backend/service/UserServiceTest.java b/commafeed-server/src/test/java/com/commafeed/backend/service/UserServiceTest.java index 8bfeed75..fa97b261 100644 --- a/commafeed-server/src/test/java/com/commafeed/backend/service/UserServiceTest.java +++ b/commafeed-server/src/test/java/com/commafeed/backend/service/UserServiceTest.java @@ -1,194 +1,194 @@ -package com.commafeed.backend.service; - -import java.util.Optional; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.junit.jupiter.MockitoExtension; - -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.dao.FeedCategoryDAO; -import com.commafeed.backend.dao.FeedSubscriptionDAO; -import com.commafeed.backend.dao.UserDAO; -import com.commafeed.backend.dao.UserRoleDAO; -import com.commafeed.backend.dao.UserSettingsDAO; -import com.commafeed.backend.model.User; -import com.commafeed.backend.service.internal.PostLoginActivities; - -@ExtendWith(MockitoExtension.class) -class UserServiceTest { - - private static final byte[] SALT = new byte[] { 1, 2, 3 }; - private static final byte[] ENCRYPTED_PASSWORD = new byte[] { 5, 6, 7 }; - - @Mock - private CommaFeedConfiguration commaFeedConfiguration; - @Mock - private FeedCategoryDAO feedCategoryDAO; - @Mock - private FeedSubscriptionDAO feedSubscriptionDAO; - @Mock - private UserDAO userDAO; - @Mock - private UserSettingsDAO userSettingsDAO; - @Mock - private UserRoleDAO userRoleDAO; - @Mock - private PasswordEncryptionService passwordEncryptionService; - @Mock - private PostLoginActivities postLoginActivities; - - private User disabledUser; - private User normalUser; - - private UserService userService; - - @BeforeEach - public void init() { - userService = new UserService(feedCategoryDAO, feedSubscriptionDAO, userDAO, userRoleDAO, userSettingsDAO, - passwordEncryptionService, commaFeedConfiguration, postLoginActivities); - - disabledUser = new User(); - disabledUser.setDisabled(true); - - normalUser = new User(); - normalUser.setDisabled(false); - normalUser.setSalt(SALT); - normalUser.setPassword(ENCRYPTED_PASSWORD); - } - - @Test - void callingLoginShouldNotReturnUserObjectWhenGivenNullNameOrEmail() { - Optional user = userService.login(null, "password"); - Assertions.assertFalse(user.isPresent()); - } - - @Test - void callingLoginShouldNotReturnUserObjectWhenGivenNullPassword() { - Optional user = userService.login("testusername", null); - Assertions.assertFalse(user.isPresent()); - } - - @Test - void callingLoginShouldLookupUserByName() { - userService.login("test", "password"); - Mockito.verify(userDAO).findByName("test"); - } - - @Test - void callingLoginShouldLookupUserByEmailIfLookupByNameFailed() { - Mockito.when(userDAO.findByName("test@test.com")).thenReturn(null); - userService.login("test@test.com", "password"); - Mockito.verify(userDAO).findByEmail("test@test.com"); - } - - @Test - void callingLoginShouldNotReturnUserObjectIfCouldNotFindUserByNameOrEmail() { - Mockito.when(userDAO.findByName("test@test.com")).thenReturn(null); - Mockito.when(userDAO.findByEmail("test@test.com")).thenReturn(null); - - Optional user = userService.login("test@test.com", "password"); - - Assertions.assertFalse(user.isPresent()); - } - - @Test - void callingLoginShouldNotReturnUserObjectIfUserIsDisabled() { - Mockito.when(userDAO.findByName("test")).thenReturn(disabledUser); - Optional user = userService.login("test", "password"); - Assertions.assertFalse(user.isPresent()); - } - - @Test - void callingLoginShouldTryToAuthenticateUserWhoIsNotDisabled() { - Mockito.when(userDAO.findByName("test")).thenReturn(normalUser); - Mockito.when(passwordEncryptionService.authenticate(Mockito.anyString(), Mockito.any(byte[].class), Mockito.any(byte[].class))) - .thenReturn(false); - - userService.login("test", "password"); - - Mockito.verify(passwordEncryptionService).authenticate("password", ENCRYPTED_PASSWORD, SALT); - } - - @Test - void callingLoginShouldNotReturnUserObjectOnUnsuccessfulAuthentication() { - Mockito.when(userDAO.findByName("test")).thenReturn(normalUser); - Mockito.when(passwordEncryptionService.authenticate(Mockito.anyString(), Mockito.any(byte[].class), Mockito.any(byte[].class))) - .thenReturn(false); - - Optional authenticatedUser = userService.login("test", "password"); - - Assertions.assertFalse(authenticatedUser.isPresent()); - } - - @Test - void callingLoginShouldExecutePostLoginActivitiesForUserOnSuccessfulAuthentication() { - Mockito.when(userDAO.findByName("test")).thenReturn(normalUser); - Mockito.when(passwordEncryptionService.authenticate(Mockito.anyString(), Mockito.any(byte[].class), Mockito.any(byte[].class))) - .thenReturn(true); - Mockito.doNothing().when(postLoginActivities).executeFor(Mockito.any(User.class)); - - userService.login("test", "password"); - - Mockito.verify(postLoginActivities).executeFor(normalUser); - } - - @Test - void callingLoginShouldReturnUserObjectOnSuccessfulAuthentication() { - Mockito.when(userDAO.findByName("test")).thenReturn(normalUser); - Mockito.when(passwordEncryptionService.authenticate(Mockito.anyString(), Mockito.any(byte[].class), Mockito.any(byte[].class))) - .thenReturn(true); - Mockito.doNothing().when(postLoginActivities).executeFor(Mockito.any(User.class)); - - Optional authenticatedUser = userService.login("test", "password"); - - Assertions.assertTrue(authenticatedUser.isPresent()); - Assertions.assertEquals(normalUser, authenticatedUser.get()); - } - - @Test - void apiLoginShouldNotReturnUserIfApikeyNull() { - Optional user = userService.login(null); - Assertions.assertFalse(user.isPresent()); - } - - @Test - void apiLoginShouldLookupUserByApikey() { - Mockito.when(userDAO.findByApiKey("apikey")).thenReturn(null); - userService.login("apikey"); - Mockito.verify(userDAO).findByApiKey("apikey"); - } - - @Test - void apiLoginShouldNotReturnUserIfUserNotFoundFromLookupByApikey() { - Mockito.when(userDAO.findByApiKey("apikey")).thenReturn(null); - Optional user = userService.login("apikey"); - Assertions.assertFalse(user.isPresent()); - } - - @Test - void apiLoginShouldNotReturnUserIfUserFoundFromApikeyLookupIsDisabled() { - Mockito.when(userDAO.findByApiKey("apikey")).thenReturn(disabledUser); - Optional user = userService.login("apikey"); - Assertions.assertFalse(user.isPresent()); - } - - @Test - void apiLoginShouldPerformPostLoginActivitiesIfUserFoundFromApikeyLookupNotDisabled() { - Mockito.when(userDAO.findByApiKey("apikey")).thenReturn(normalUser); - userService.login("apikey"); - Mockito.verify(postLoginActivities).executeFor(normalUser); - } - - @Test - void apiLoginShouldReturnUserIfUserFoundFromApikeyLookupNotDisabled() { - Mockito.when(userDAO.findByApiKey("apikey")).thenReturn(normalUser); - Optional returnedUser = userService.login("apikey"); - Assertions.assertEquals(normalUser, returnedUser.get()); - } - -} +package com.commafeed.backend.service; + +import java.util.Optional; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.backend.dao.FeedCategoryDAO; +import com.commafeed.backend.dao.FeedSubscriptionDAO; +import com.commafeed.backend.dao.UserDAO; +import com.commafeed.backend.dao.UserRoleDAO; +import com.commafeed.backend.dao.UserSettingsDAO; +import com.commafeed.backend.model.User; +import com.commafeed.backend.service.internal.PostLoginActivities; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + private static final byte[] SALT = new byte[] { 1, 2, 3 }; + private static final byte[] ENCRYPTED_PASSWORD = new byte[] { 5, 6, 7 }; + + @Mock + private CommaFeedConfiguration commaFeedConfiguration; + @Mock + private FeedCategoryDAO feedCategoryDAO; + @Mock + private FeedSubscriptionDAO feedSubscriptionDAO; + @Mock + private UserDAO userDAO; + @Mock + private UserSettingsDAO userSettingsDAO; + @Mock + private UserRoleDAO userRoleDAO; + @Mock + private PasswordEncryptionService passwordEncryptionService; + @Mock + private PostLoginActivities postLoginActivities; + + private User disabledUser; + private User normalUser; + + private UserService userService; + + @BeforeEach + public void init() { + userService = new UserService(feedCategoryDAO, feedSubscriptionDAO, userDAO, userRoleDAO, userSettingsDAO, + passwordEncryptionService, commaFeedConfiguration, postLoginActivities); + + disabledUser = new User(); + disabledUser.setDisabled(true); + + normalUser = new User(); + normalUser.setDisabled(false); + normalUser.setSalt(SALT); + normalUser.setPassword(ENCRYPTED_PASSWORD); + } + + @Test + void callingLoginShouldNotReturnUserObjectWhenGivenNullNameOrEmail() { + Optional user = userService.login(null, "password"); + Assertions.assertFalse(user.isPresent()); + } + + @Test + void callingLoginShouldNotReturnUserObjectWhenGivenNullPassword() { + Optional user = userService.login("testusername", null); + Assertions.assertFalse(user.isPresent()); + } + + @Test + void callingLoginShouldLookupUserByName() { + userService.login("test", "password"); + Mockito.verify(userDAO).findByName("test"); + } + + @Test + void callingLoginShouldLookupUserByEmailIfLookupByNameFailed() { + Mockito.when(userDAO.findByName("test@test.com")).thenReturn(null); + userService.login("test@test.com", "password"); + Mockito.verify(userDAO).findByEmail("test@test.com"); + } + + @Test + void callingLoginShouldNotReturnUserObjectIfCouldNotFindUserByNameOrEmail() { + Mockito.when(userDAO.findByName("test@test.com")).thenReturn(null); + Mockito.when(userDAO.findByEmail("test@test.com")).thenReturn(null); + + Optional user = userService.login("test@test.com", "password"); + + Assertions.assertFalse(user.isPresent()); + } + + @Test + void callingLoginShouldNotReturnUserObjectIfUserIsDisabled() { + Mockito.when(userDAO.findByName("test")).thenReturn(disabledUser); + Optional user = userService.login("test", "password"); + Assertions.assertFalse(user.isPresent()); + } + + @Test + void callingLoginShouldTryToAuthenticateUserWhoIsNotDisabled() { + Mockito.when(userDAO.findByName("test")).thenReturn(normalUser); + Mockito.when(passwordEncryptionService.authenticate(Mockito.anyString(), Mockito.any(byte[].class), Mockito.any(byte[].class))) + .thenReturn(false); + + userService.login("test", "password"); + + Mockito.verify(passwordEncryptionService).authenticate("password", ENCRYPTED_PASSWORD, SALT); + } + + @Test + void callingLoginShouldNotReturnUserObjectOnUnsuccessfulAuthentication() { + Mockito.when(userDAO.findByName("test")).thenReturn(normalUser); + Mockito.when(passwordEncryptionService.authenticate(Mockito.anyString(), Mockito.any(byte[].class), Mockito.any(byte[].class))) + .thenReturn(false); + + Optional authenticatedUser = userService.login("test", "password"); + + Assertions.assertFalse(authenticatedUser.isPresent()); + } + + @Test + void callingLoginShouldExecutePostLoginActivitiesForUserOnSuccessfulAuthentication() { + Mockito.when(userDAO.findByName("test")).thenReturn(normalUser); + Mockito.when(passwordEncryptionService.authenticate(Mockito.anyString(), Mockito.any(byte[].class), Mockito.any(byte[].class))) + .thenReturn(true); + Mockito.doNothing().when(postLoginActivities).executeFor(Mockito.any(User.class)); + + userService.login("test", "password"); + + Mockito.verify(postLoginActivities).executeFor(normalUser); + } + + @Test + void callingLoginShouldReturnUserObjectOnSuccessfulAuthentication() { + Mockito.when(userDAO.findByName("test")).thenReturn(normalUser); + Mockito.when(passwordEncryptionService.authenticate(Mockito.anyString(), Mockito.any(byte[].class), Mockito.any(byte[].class))) + .thenReturn(true); + Mockito.doNothing().when(postLoginActivities).executeFor(Mockito.any(User.class)); + + Optional authenticatedUser = userService.login("test", "password"); + + Assertions.assertTrue(authenticatedUser.isPresent()); + Assertions.assertEquals(normalUser, authenticatedUser.get()); + } + + @Test + void apiLoginShouldNotReturnUserIfApikeyNull() { + Optional user = userService.login(null); + Assertions.assertFalse(user.isPresent()); + } + + @Test + void apiLoginShouldLookupUserByApikey() { + Mockito.when(userDAO.findByApiKey("apikey")).thenReturn(null); + userService.login("apikey"); + Mockito.verify(userDAO).findByApiKey("apikey"); + } + + @Test + void apiLoginShouldNotReturnUserIfUserNotFoundFromLookupByApikey() { + Mockito.when(userDAO.findByApiKey("apikey")).thenReturn(null); + Optional user = userService.login("apikey"); + Assertions.assertFalse(user.isPresent()); + } + + @Test + void apiLoginShouldNotReturnUserIfUserFoundFromApikeyLookupIsDisabled() { + Mockito.when(userDAO.findByApiKey("apikey")).thenReturn(disabledUser); + Optional user = userService.login("apikey"); + Assertions.assertFalse(user.isPresent()); + } + + @Test + void apiLoginShouldPerformPostLoginActivitiesIfUserFoundFromApikeyLookupNotDisabled() { + Mockito.when(userDAO.findByApiKey("apikey")).thenReturn(normalUser); + userService.login("apikey"); + Mockito.verify(postLoginActivities).executeFor(normalUser); + } + + @Test + void apiLoginShouldReturnUserIfUserFoundFromApikeyLookupNotDisabled() { + Mockito.when(userDAO.findByApiKey("apikey")).thenReturn(normalUser); + Optional returnedUser = userService.login("apikey"); + Assertions.assertEquals(normalUser, returnedUser.get()); + } + +} diff --git a/commafeed-server/src/test/java/com/commafeed/backend/urlprovider/YoutubeFeedURLProviderTest.java b/commafeed-server/src/test/java/com/commafeed/backend/urlprovider/YoutubeFeedURLProviderTest.java index 16fcc119..0788e7dc 100644 --- a/commafeed-server/src/test/java/com/commafeed/backend/urlprovider/YoutubeFeedURLProviderTest.java +++ b/commafeed-server/src/test/java/com/commafeed/backend/urlprovider/YoutubeFeedURLProviderTest.java @@ -1,22 +1,22 @@ -package com.commafeed.backend.urlprovider; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -class YoutubeFeedURLProviderTest { - - private final YoutubeFeedURLProvider provider = new YoutubeFeedURLProvider(); - - @Test - void matchesYoutubeChannelURL() { - Assertions.assertEquals("https://www.youtube.com/feeds/videos.xml?channel_id=abc", - provider.get("https://www.youtube.com/channel/abc", null)); - } - - @Test - void doesNotmatchYoutubeChannelURL() { - Assertions.assertNull(provider.get("https://www.anothersite.com/channel/abc", null)); - Assertions.assertNull(provider.get("https://www.youtube.com/user/abc", null)); - } - +package com.commafeed.backend.urlprovider; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class YoutubeFeedURLProviderTest { + + private final YoutubeFeedURLProvider provider = new YoutubeFeedURLProvider(); + + @Test + void matchesYoutubeChannelURL() { + Assertions.assertEquals("https://www.youtube.com/feeds/videos.xml?channel_id=abc", + provider.get("https://www.youtube.com/channel/abc", null)); + } + + @Test + void doesNotmatchYoutubeChannelURL() { + Assertions.assertNull(provider.get("https://www.anothersite.com/channel/abc", null)); + Assertions.assertNull(provider.get("https://www.youtube.com/user/abc", null)); + } + } \ No newline at end of file diff --git a/commafeed-server/src/test/java/com/commafeed/e2e/AuthentificationIT.java b/commafeed-server/src/test/java/com/commafeed/e2e/AuthentificationIT.java index 2fcfbbf6..af9698fc 100644 --- a/commafeed-server/src/test/java/com/commafeed/e2e/AuthentificationIT.java +++ b/commafeed-server/src/test/java/com/commafeed/e2e/AuthentificationIT.java @@ -1,80 +1,80 @@ -package com.commafeed.e2e; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import com.microsoft.playwright.Browser; -import com.microsoft.playwright.Locator; -import com.microsoft.playwright.Page; -import com.microsoft.playwright.Playwright; -import com.microsoft.playwright.assertions.PlaywrightAssertions; -import com.microsoft.playwright.options.AriaRole; - -import io.quarkus.test.junit.QuarkusTest; - -@QuarkusTest -class AuthentificationIT { - - private final Playwright playwright = Playwright.create(); - private final Browser browser = playwright.chromium().launch(); - - private Page page; - - @BeforeEach - void init() { - page = browser.newContext().newPage(); - } - - @AfterEach - void cleanup() { - playwright.close(); - } - - @Test - void loginFail() { - page.navigate(getLoginPageUrl()); - page.getByPlaceholder("User Name or E-mail").fill("admin"); - page.getByPlaceholder("Password").fill("wrong_password"); - page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Log in")).click(); - PlaywrightAssertions.assertThat(page.getByRole(AriaRole.ALERT)).containsText("wrong username or password"); - } - - @Test - void loginSuccess() { - page.navigate(getLoginPageUrl()); - PlaywrightTestUtils.login(page); - PlaywrightAssertions.assertThat(page).hasURL("http://localhost:8085/#/app/category/all"); - } - - @Test - void registerFailPasswordTooSimple() { - page.navigate(getLoginPageUrl()); - page.getByText("Sign up!").click(); - page.getByPlaceholder("User Name").fill("user"); - page.getByPlaceholder("E-mail address").fill("user@domain.com"); - page.getByPlaceholder("Password").fill("pass"); - page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Sign up")).click(); - - Locator alert = page.getByRole(AriaRole.ALERT); - PlaywrightAssertions.assertThat(alert).containsText("Password must be 8 or more characters in length."); - PlaywrightAssertions.assertThat(alert).containsText("Password must contain 1 or more uppercase characters."); - PlaywrightAssertions.assertThat(alert).containsText("Password must contain 1 or more digit characters."); - PlaywrightAssertions.assertThat(alert).containsText("Password must contain 1 or more special characters."); - } - - @Test - void registerSuccess() { - page.navigate(getLoginPageUrl()); - page.getByText("Sign up!").click(); - page.getByPlaceholder("User Name").fill("user"); - page.getByPlaceholder("E-mail address").fill("user@domain.com"); - page.getByPlaceholder("Password").fill("MyPassword1!"); - page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Sign up")).click(); - PlaywrightAssertions.assertThat(page).hasURL("http://localhost:8085/#/app/category/all"); - } - - private String getLoginPageUrl() { - return "http://localhost:8085/#/login"; - } -} +package com.commafeed.e2e; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import com.microsoft.playwright.assertions.PlaywrightAssertions; +import com.microsoft.playwright.options.AriaRole; + +import io.quarkus.test.junit.QuarkusTest; + +@QuarkusTest +class AuthentificationIT { + + private final Playwright playwright = Playwright.create(); + private final Browser browser = playwright.chromium().launch(); + + private Page page; + + @BeforeEach + void init() { + page = browser.newContext().newPage(); + } + + @AfterEach + void cleanup() { + playwright.close(); + } + + @Test + void loginFail() { + page.navigate(getLoginPageUrl()); + page.getByPlaceholder("User Name or E-mail").fill("admin"); + page.getByPlaceholder("Password").fill("wrong_password"); + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Log in")).click(); + PlaywrightAssertions.assertThat(page.getByRole(AriaRole.ALERT)).containsText("wrong username or password"); + } + + @Test + void loginSuccess() { + page.navigate(getLoginPageUrl()); + PlaywrightTestUtils.login(page); + PlaywrightAssertions.assertThat(page).hasURL("http://localhost:8085/#/app/category/all"); + } + + @Test + void registerFailPasswordTooSimple() { + page.navigate(getLoginPageUrl()); + page.getByText("Sign up!").click(); + page.getByPlaceholder("User Name").fill("user"); + page.getByPlaceholder("E-mail address").fill("user@domain.com"); + page.getByPlaceholder("Password").fill("pass"); + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Sign up")).click(); + + Locator alert = page.getByRole(AriaRole.ALERT); + PlaywrightAssertions.assertThat(alert).containsText("Password must be 8 or more characters in length."); + PlaywrightAssertions.assertThat(alert).containsText("Password must contain 1 or more uppercase characters."); + PlaywrightAssertions.assertThat(alert).containsText("Password must contain 1 or more digit characters."); + PlaywrightAssertions.assertThat(alert).containsText("Password must contain 1 or more special characters."); + } + + @Test + void registerSuccess() { + page.navigate(getLoginPageUrl()); + page.getByText("Sign up!").click(); + page.getByPlaceholder("User Name").fill("user"); + page.getByPlaceholder("E-mail address").fill("user@domain.com"); + page.getByPlaceholder("Password").fill("MyPassword1!"); + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Sign up")).click(); + PlaywrightAssertions.assertThat(page).hasURL("http://localhost:8085/#/app/category/all"); + } + + private String getLoginPageUrl() { + return "http://localhost:8085/#/login"; + } +} diff --git a/commafeed-server/src/test/java/com/commafeed/e2e/DocumentationIT.java b/commafeed-server/src/test/java/com/commafeed/e2e/DocumentationIT.java index f8040ac3..a4dc1b75 100644 --- a/commafeed-server/src/test/java/com/commafeed/e2e/DocumentationIT.java +++ b/commafeed-server/src/test/java/com/commafeed/e2e/DocumentationIT.java @@ -1,38 +1,38 @@ -package com.commafeed.e2e; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import com.microsoft.playwright.Browser; -import com.microsoft.playwright.Page; -import com.microsoft.playwright.Playwright; -import com.microsoft.playwright.assertions.PlaywrightAssertions; - -import io.quarkus.test.junit.QuarkusTest; - -@QuarkusTest -class DocumentationIT { - - private final Playwright playwright = Playwright.create(); - private final Browser browser = playwright.chromium().launch(); - - private Page page; - - @BeforeEach - void init() { - page = browser.newContext().newPage(); - } - - @AfterEach - void cleanup() { - playwright.close(); - } - - @Test - void documentationAvailable() { - page.navigate("http://localhost:8085/#/api"); - PlaywrightAssertions.assertThat(page.getByText("Download OpenAPI specification:")).isVisible(); - } - -} +package com.commafeed.e2e; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import com.microsoft.playwright.assertions.PlaywrightAssertions; + +import io.quarkus.test.junit.QuarkusTest; + +@QuarkusTest +class DocumentationIT { + + private final Playwright playwright = Playwright.create(); + private final Browser browser = playwright.chromium().launch(); + + private Page page; + + @BeforeEach + void init() { + page = browser.newContext().newPage(); + } + + @AfterEach + void cleanup() { + playwright.close(); + } + + @Test + void documentationAvailable() { + page.navigate("http://localhost:8085/#/api"); + PlaywrightAssertions.assertThat(page.getByText("Download OpenAPI specification:")).isVisible(); + } + +} diff --git a/commafeed-server/src/test/java/com/commafeed/e2e/PlaywrightTestUtils.java b/commafeed-server/src/test/java/com/commafeed/e2e/PlaywrightTestUtils.java index d0f74b80..82370658 100644 --- a/commafeed-server/src/test/java/com/commafeed/e2e/PlaywrightTestUtils.java +++ b/commafeed-server/src/test/java/com/commafeed/e2e/PlaywrightTestUtils.java @@ -1,18 +1,18 @@ -package com.commafeed.e2e; - -import com.microsoft.playwright.Page; -import com.microsoft.playwright.Page.GetByRoleOptions; -import com.microsoft.playwright.options.AriaRole; - -import lombok.experimental.UtilityClass; - -@UtilityClass -public class PlaywrightTestUtils { - - public static void login(Page page) { - page.getByPlaceholder("User Name or E-mail").fill("admin"); - page.getByPlaceholder("Password").fill("admin"); - page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Log in")).click(); - } - -} +package com.commafeed.e2e; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Page.GetByRoleOptions; +import com.microsoft.playwright.options.AriaRole; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public class PlaywrightTestUtils { + + public static void login(Page page) { + page.getByPlaceholder("User Name or E-mail").fill("admin"); + page.getByPlaceholder("Password").fill("admin"); + page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Log in")).click(); + } + +} diff --git a/commafeed-server/src/test/java/com/commafeed/e2e/ReadingIT.java b/commafeed-server/src/test/java/com/commafeed/e2e/ReadingIT.java index f2dc9803..56b6f9bf 100644 --- a/commafeed-server/src/test/java/com/commafeed/e2e/ReadingIT.java +++ b/commafeed-server/src/test/java/com/commafeed/e2e/ReadingIT.java @@ -1,110 +1,110 @@ -package com.commafeed.e2e; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.TimeUnit; -import java.util.regex.Pattern; - -import org.apache.commons.io.IOUtils; -import org.apache.hc.core5.http.HttpStatus; -import org.awaitility.Awaitility; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockserver.client.MockServerClient; -import org.mockserver.integration.ClientAndServer; -import org.mockserver.model.HttpRequest; -import org.mockserver.model.HttpResponse; - -import com.commafeed.frontend.model.Entries; -import com.microsoft.playwright.Browser; -import com.microsoft.playwright.Locator; -import com.microsoft.playwright.Page; -import com.microsoft.playwright.Playwright; -import com.microsoft.playwright.assertions.PlaywrightAssertions; -import com.microsoft.playwright.options.AriaRole; - -import io.quarkus.test.junit.QuarkusTest; -import io.restassured.RestAssured; - -@QuarkusTest -class ReadingIT { - - private final Playwright playwright = Playwright.create(); - private final Browser browser = playwright.chromium().launch(); - - private Page page; - private MockServerClient mockServerClient; - - @BeforeEach - void init() throws IOException { - this.page = browser.newContext().newPage(); - this.mockServerClient = ClientAndServer.startClientAndServer(0); - this.mockServerClient.when(HttpRequest.request().withMethod("GET")) - .respond(HttpResponse.response() - .withBody(IOUtils.toString(getClass().getResource("/feed/rss.xml"), StandardCharsets.UTF_8)) - .withDelay(TimeUnit.MILLISECONDS, 100)); - - RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); - } - - @AfterEach - void cleanup() { - playwright.close(); - - RestAssured.reset(); - } - - @Test - void scenario() { - // login - page.navigate("http://localhost:8085"); - page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Log in")).click(); - PlaywrightTestUtils.login(page); - - Locator header = page.getByRole(AriaRole.BANNER); - Locator sidebar = page.getByRole(AriaRole.NAVIGATION); - Locator main = page.getByRole(AriaRole.MAIN); - - PlaywrightAssertions.assertThat(main.getByText("You don't have any subscriptions yet.")).hasCount(1); - - // subscribe - header.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("Subscribe")).click(); - main.getByText("Feed URL *").fill("http://localhost:" + this.mockServerClient.getPort()); - main.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("Next")).click(); - main.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("Subscribe").setExact(true)).click(); - - // click on subscription - sidebar.getByText(Pattern.compile("CommaFeed test feed\\d+")).click(); - - // we have two unread entries - PlaywrightAssertions.assertThat(main.getByRole(AriaRole.ARTICLE)).hasCount(2); - - // click on first entry - main.getByText("Item 1").click(); - PlaywrightAssertions.assertThat(main.getByText("Item 1 description")).hasCount(1); - PlaywrightAssertions.assertThat(main.getByText("Item 2 description")).hasCount(0); - - // wait for the entry to be marked as read since the UI is updated immediately while the entry is marked as read in the background - Awaitility.await() - .atMost(15, TimeUnit.SECONDS) - .until(() -> RestAssured.given() - .get("rest/category/entries?id=all&readType=unread") - .then() - .statusCode(HttpStatus.SC_OK) - .extract() - .as(Entries.class), e -> e.getEntries().size() == 1); - - // click on subscription - sidebar.getByText(Pattern.compile("CommaFeed test feed\\d*")).click(); - - // only one unread entry now - PlaywrightAssertions.assertThat(main.getByRole(AriaRole.ARTICLE)).hasCount(1); - - // click on second entry - main.getByText("Item 2").click(); - PlaywrightAssertions.assertThat(main.getByText("Item 1 description")).hasCount(0); - PlaywrightAssertions.assertThat(main.getByText("Item 2 description")).hasCount(1); - } - -} +package com.commafeed.e2e; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +import org.apache.commons.io.IOUtils; +import org.apache.hc.core5.http.HttpStatus; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockserver.client.MockServerClient; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; + +import com.commafeed.frontend.model.Entries; +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import com.microsoft.playwright.assertions.PlaywrightAssertions; +import com.microsoft.playwright.options.AriaRole; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; + +@QuarkusTest +class ReadingIT { + + private final Playwright playwright = Playwright.create(); + private final Browser browser = playwright.chromium().launch(); + + private Page page; + private MockServerClient mockServerClient; + + @BeforeEach + void init() throws IOException { + this.page = browser.newContext().newPage(); + this.mockServerClient = ClientAndServer.startClientAndServer(0); + this.mockServerClient.when(HttpRequest.request().withMethod("GET")) + .respond(HttpResponse.response() + .withBody(IOUtils.toString(getClass().getResource("/feed/rss.xml"), StandardCharsets.UTF_8)) + .withDelay(TimeUnit.MILLISECONDS, 100)); + + RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); + } + + @AfterEach + void cleanup() { + playwright.close(); + + RestAssured.reset(); + } + + @Test + void scenario() { + // login + page.navigate("http://localhost:8085"); + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Log in")).click(); + PlaywrightTestUtils.login(page); + + Locator header = page.getByRole(AriaRole.BANNER); + Locator sidebar = page.getByRole(AriaRole.NAVIGATION); + Locator main = page.getByRole(AriaRole.MAIN); + + PlaywrightAssertions.assertThat(main.getByText("You don't have any subscriptions yet.")).hasCount(1); + + // subscribe + header.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("Subscribe")).click(); + main.getByText("Feed URL *").fill("http://localhost:" + this.mockServerClient.getPort()); + main.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("Next")).click(); + main.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("Subscribe").setExact(true)).click(); + + // click on subscription + sidebar.getByText(Pattern.compile("CommaFeed test feed\\d+")).click(); + + // we have two unread entries + PlaywrightAssertions.assertThat(main.getByRole(AriaRole.ARTICLE)).hasCount(2); + + // click on first entry + main.getByText("Item 1").click(); + PlaywrightAssertions.assertThat(main.getByText("Item 1 description")).hasCount(1); + PlaywrightAssertions.assertThat(main.getByText("Item 2 description")).hasCount(0); + + // wait for the entry to be marked as read since the UI is updated immediately while the entry is marked as read in the background + Awaitility.await() + .atMost(15, TimeUnit.SECONDS) + .until(() -> RestAssured.given() + .get("rest/category/entries?id=all&readType=unread") + .then() + .statusCode(HttpStatus.SC_OK) + .extract() + .as(Entries.class), e -> e.getEntries().size() == 1); + + // click on subscription + sidebar.getByText(Pattern.compile("CommaFeed test feed\\d*")).click(); + + // only one unread entry now + PlaywrightAssertions.assertThat(main.getByRole(AriaRole.ARTICLE)).hasCount(1); + + // click on second entry + main.getByText("Item 2").click(); + PlaywrightAssertions.assertThat(main.getByText("Item 1 description")).hasCount(0); + PlaywrightAssertions.assertThat(main.getByText("Item 2 description")).hasCount(1); + } + +} diff --git a/commafeed-server/src/test/java/com/commafeed/frontend/ws/WebSocketSessionsTest.java b/commafeed-server/src/test/java/com/commafeed/frontend/ws/WebSocketSessionsTest.java index 4461c473..f26ca314 100644 --- a/commafeed-server/src/test/java/com/commafeed/frontend/ws/WebSocketSessionsTest.java +++ b/commafeed-server/src/test/java/com/commafeed/frontend/ws/WebSocketSessionsTest.java @@ -1,82 +1,82 @@ -package com.commafeed.frontend.ws; - -import jakarta.websocket.Session; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Answers; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.junit.jupiter.MockitoExtension; - -import com.codahale.metrics.MetricRegistry; -import com.commafeed.backend.model.User; - -@ExtendWith(MockitoExtension.class) -class WebSocketSessionsTest { - - @Mock - private MetricRegistry metrics; - - @Mock(answer = Answers.RETURNS_DEEP_STUBS) - private Session session1; - - @Mock(answer = Answers.RETURNS_DEEP_STUBS) - private Session session2; - - @Mock(answer = Answers.RETURNS_DEEP_STUBS) - private Session session3; - - private WebSocketSessions webSocketSessions; - - @BeforeEach - void init() { - webSocketSessions = new WebSocketSessions(metrics); - } - - @Test - void sendsMessageToUser() { - Mockito.when(session1.isOpen()).thenReturn(true); - Mockito.when(session2.isOpen()).thenReturn(true); - - User user1 = newUser(1L); - webSocketSessions.add(user1.getId(), session1); - webSocketSessions.add(user1.getId(), session2); - - User user2 = newUser(2L); - webSocketSessions.add(user2.getId(), session3); - - webSocketSessions.sendMessage(user1, "Hello"); - Mockito.verify(session1).getAsyncRemote(); - Mockito.verify(session2).getAsyncRemote(); - Mockito.verifyNoInteractions(session3); - } - - @Test - void closedSessionsAreNotNotified() { - Mockito.when(session1.isOpen()).thenReturn(false); - - User user1 = newUser(1L); - webSocketSessions.add(user1.getId(), session1); - - webSocketSessions.sendMessage(user1, "Hello"); - Mockito.verify(session1, Mockito.never()).getAsyncRemote(); - } - - @Test - void removedSessionsAreNotNotified() { - User user1 = newUser(1L); - webSocketSessions.add(user1.getId(), session1); - webSocketSessions.remove(session1); - - webSocketSessions.sendMessage(user1, "Hello"); - Mockito.verifyNoInteractions(session1); - } - - private User newUser(Long userId) { - User user = new User(); - user.setId(userId); - return user; - } +package com.commafeed.frontend.ws; + +import jakarta.websocket.Session; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.codahale.metrics.MetricRegistry; +import com.commafeed.backend.model.User; + +@ExtendWith(MockitoExtension.class) +class WebSocketSessionsTest { + + @Mock + private MetricRegistry metrics; + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private Session session1; + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private Session session2; + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private Session session3; + + private WebSocketSessions webSocketSessions; + + @BeforeEach + void init() { + webSocketSessions = new WebSocketSessions(metrics); + } + + @Test + void sendsMessageToUser() { + Mockito.when(session1.isOpen()).thenReturn(true); + Mockito.when(session2.isOpen()).thenReturn(true); + + User user1 = newUser(1L); + webSocketSessions.add(user1.getId(), session1); + webSocketSessions.add(user1.getId(), session2); + + User user2 = newUser(2L); + webSocketSessions.add(user2.getId(), session3); + + webSocketSessions.sendMessage(user1, "Hello"); + Mockito.verify(session1).getAsyncRemote(); + Mockito.verify(session2).getAsyncRemote(); + Mockito.verifyNoInteractions(session3); + } + + @Test + void closedSessionsAreNotNotified() { + Mockito.when(session1.isOpen()).thenReturn(false); + + User user1 = newUser(1L); + webSocketSessions.add(user1.getId(), session1); + + webSocketSessions.sendMessage(user1, "Hello"); + Mockito.verify(session1, Mockito.never()).getAsyncRemote(); + } + + @Test + void removedSessionsAreNotNotified() { + User user1 = newUser(1L); + webSocketSessions.add(user1.getId(), session1); + webSocketSessions.remove(session1); + + webSocketSessions.sendMessage(user1, "Hello"); + Mockito.verifyNoInteractions(session1); + } + + private User newUser(Long userId) { + User user = new User(); + user.setId(userId); + return user; + } } \ No newline at end of file diff --git a/commafeed-server/src/test/java/com/commafeed/integration/BaseIT.java b/commafeed-server/src/test/java/com/commafeed/integration/BaseIT.java index 19242bd6..22953e38 100644 --- a/commafeed-server/src/test/java/com/commafeed/integration/BaseIT.java +++ b/commafeed-server/src/test/java/com/commafeed/integration/BaseIT.java @@ -1,132 +1,132 @@ -package com.commafeed.integration; - -import java.io.IOException; -import java.net.HttpCookie; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.List; -import java.util.Objects; - -import jakarta.ws.rs.client.Client; -import jakarta.ws.rs.core.HttpHeaders; -import jakarta.ws.rs.core.MediaType; - -import org.apache.commons.io.IOUtils; -import org.apache.hc.core5.http.HttpStatus; -import org.awaitility.Awaitility; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.mockserver.client.MockServerClient; -import org.mockserver.integration.ClientAndServer; -import org.mockserver.model.HttpRequest; -import org.mockserver.model.HttpResponse; - -import com.commafeed.frontend.model.Entries; -import com.commafeed.frontend.model.Subscription; -import com.commafeed.frontend.model.request.SubscribeRequest; - -import io.restassured.RestAssured; -import io.restassured.http.Header; -import lombok.Getter; - -@Getter -public abstract class BaseIT { - - private static final HttpRequest FEED_REQUEST = HttpRequest.request().withMethod("GET").withPath("/"); - - private MockServerClient mockServerClient; - private Client client; - private String feedUrl; - private String baseUrl; - private String apiBaseUrl; - private String webSocketUrl; - - @BeforeEach - void init() throws IOException { - this.mockServerClient = ClientAndServer.startClientAndServer(0); - - this.feedUrl = "http://localhost:" + mockServerClient.getPort() + "/"; - this.baseUrl = "http://localhost:8085/"; - this.apiBaseUrl = this.baseUrl + "rest/"; - this.webSocketUrl = "ws://localhost:8085/ws"; - - URL resource = Objects.requireNonNull(getClass().getResource("/feed/rss.xml")); - this.mockServerClient.when(FEED_REQUEST) - .respond(HttpResponse.response().withBody(IOUtils.toString(resource, StandardCharsets.UTF_8))); - } - - @AfterEach - void cleanup() { - if (this.mockServerClient != null) { - this.mockServerClient.close(); - } - - if (this.client != null) { - this.client.close(); - } - } - - protected void feedNowReturnsMoreEntries() throws IOException { - mockServerClient.clear(FEED_REQUEST); - - URL resource = Objects.requireNonNull(getClass().getResource("/feed/rss_2.xml")); - mockServerClient.when(FEED_REQUEST).respond(HttpResponse.response().withBody(IOUtils.toString(resource, StandardCharsets.UTF_8))); - } - - protected List login() { - List
setCookieHeaders = RestAssured.given() - .auth() - .none() - .formParams("j_username", "admin", "j_password", "admin") - .post("j_security_check") - .then() - .statusCode(HttpStatus.SC_OK) - .extract() - .headers() - .getList(HttpHeaders.SET_COOKIE); - return setCookieHeaders.stream().flatMap(h -> HttpCookie.parse(h.getValue()).stream()).toList(); - } - - protected Long subscribe(String feedUrl) { - SubscribeRequest subscribeRequest = new SubscribeRequest(); - subscribeRequest.setUrl(feedUrl); - subscribeRequest.setTitle("my title for this feed"); - return RestAssured.given() - .body(subscribeRequest) - .contentType(MediaType.APPLICATION_JSON) - .post("rest/feed/subscribe") - .then() - .statusCode(HttpStatus.SC_OK) - .extract() - .as(Long.class); - } - - protected Long subscribeAndWaitForEntries(String feedUrl) { - Long subscriptionId = subscribe(feedUrl); - Awaitility.await().atMost(Duration.ofSeconds(15)).until(() -> getFeedEntries(subscriptionId), e -> e.getEntries().size() == 2); - return subscriptionId; - } - - protected Subscription getSubscription(Long subscriptionId) { - return RestAssured.given() - .get("rest/feed/get/{id}", subscriptionId) - .then() - .statusCode(HttpStatus.SC_OK) - .extract() - .as(Subscription.class); - } - - protected Entries getFeedEntries(long subscriptionId) { - return RestAssured.given() - .get("rest/feed/entries?id={id}&readType=all", subscriptionId) - .then() - .statusCode(HttpStatus.SC_OK) - .extract() - .as(Entries.class); - } - - protected int forceRefreshAllFeeds() { - return RestAssured.given().get("rest/feed/refreshAll").then().extract().statusCode(); - } -} +package com.commafeed.integration; + +import java.io.IOException; +import java.net.HttpCookie; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; + +import org.apache.commons.io.IOUtils; +import org.apache.hc.core5.http.HttpStatus; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.mockserver.client.MockServerClient; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; + +import com.commafeed.frontend.model.Entries; +import com.commafeed.frontend.model.Subscription; +import com.commafeed.frontend.model.request.SubscribeRequest; + +import io.restassured.RestAssured; +import io.restassured.http.Header; +import lombok.Getter; + +@Getter +public abstract class BaseIT { + + private static final HttpRequest FEED_REQUEST = HttpRequest.request().withMethod("GET").withPath("/"); + + private MockServerClient mockServerClient; + private Client client; + private String feedUrl; + private String baseUrl; + private String apiBaseUrl; + private String webSocketUrl; + + @BeforeEach + void init() throws IOException { + this.mockServerClient = ClientAndServer.startClientAndServer(0); + + this.feedUrl = "http://localhost:" + mockServerClient.getPort() + "/"; + this.baseUrl = "http://localhost:8085/"; + this.apiBaseUrl = this.baseUrl + "rest/"; + this.webSocketUrl = "ws://localhost:8085/ws"; + + URL resource = Objects.requireNonNull(getClass().getResource("/feed/rss.xml")); + this.mockServerClient.when(FEED_REQUEST) + .respond(HttpResponse.response().withBody(IOUtils.toString(resource, StandardCharsets.UTF_8))); + } + + @AfterEach + void cleanup() { + if (this.mockServerClient != null) { + this.mockServerClient.close(); + } + + if (this.client != null) { + this.client.close(); + } + } + + protected void feedNowReturnsMoreEntries() throws IOException { + mockServerClient.clear(FEED_REQUEST); + + URL resource = Objects.requireNonNull(getClass().getResource("/feed/rss_2.xml")); + mockServerClient.when(FEED_REQUEST).respond(HttpResponse.response().withBody(IOUtils.toString(resource, StandardCharsets.UTF_8))); + } + + protected List login() { + List
setCookieHeaders = RestAssured.given() + .auth() + .none() + .formParams("j_username", "admin", "j_password", "admin") + .post("j_security_check") + .then() + .statusCode(HttpStatus.SC_OK) + .extract() + .headers() + .getList(HttpHeaders.SET_COOKIE); + return setCookieHeaders.stream().flatMap(h -> HttpCookie.parse(h.getValue()).stream()).toList(); + } + + protected Long subscribe(String feedUrl) { + SubscribeRequest subscribeRequest = new SubscribeRequest(); + subscribeRequest.setUrl(feedUrl); + subscribeRequest.setTitle("my title for this feed"); + return RestAssured.given() + .body(subscribeRequest) + .contentType(MediaType.APPLICATION_JSON) + .post("rest/feed/subscribe") + .then() + .statusCode(HttpStatus.SC_OK) + .extract() + .as(Long.class); + } + + protected Long subscribeAndWaitForEntries(String feedUrl) { + Long subscriptionId = subscribe(feedUrl); + Awaitility.await().atMost(Duration.ofSeconds(15)).until(() -> getFeedEntries(subscriptionId), e -> e.getEntries().size() == 2); + return subscriptionId; + } + + protected Subscription getSubscription(Long subscriptionId) { + return RestAssured.given() + .get("rest/feed/get/{id}", subscriptionId) + .then() + .statusCode(HttpStatus.SC_OK) + .extract() + .as(Subscription.class); + } + + protected Entries getFeedEntries(long subscriptionId) { + return RestAssured.given() + .get("rest/feed/entries?id={id}&readType=all", subscriptionId) + .then() + .statusCode(HttpStatus.SC_OK) + .extract() + .as(Entries.class); + } + + protected int forceRefreshAllFeeds() { + return RestAssured.given().get("rest/feed/refreshAll").then().extract().statusCode(); + } +} diff --git a/commafeed-server/src/test/java/com/commafeed/integration/CompressionIT.java b/commafeed-server/src/test/java/com/commafeed/integration/CompressionIT.java index 24a7096b..1a458b90 100644 --- a/commafeed-server/src/test/java/com/commafeed/integration/CompressionIT.java +++ b/commafeed-server/src/test/java/com/commafeed/integration/CompressionIT.java @@ -1,19 +1,19 @@ -package com.commafeed.integration; - -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import com.google.common.net.HttpHeaders; - -import io.quarkus.test.junit.QuarkusTest; -import io.restassured.RestAssured; - -@QuarkusTest -class CompressionIT { - - @ParameterizedTest - @ValueSource(strings = { "/rest/server/get", "/openapi.json" }) - void servedWithCompression(String path) { - RestAssured.given().when().get(path).then().statusCode(200).header(HttpHeaders.CONTENT_ENCODING, "gzip"); - } -} +package com.commafeed.integration; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.google.common.net.HttpHeaders; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; + +@QuarkusTest +class CompressionIT { + + @ParameterizedTest + @ValueSource(strings = { "/rest/server/get", "/openapi.json" }) + void servedWithCompression(String path) { + RestAssured.given().when().get(path).then().statusCode(200).header(HttpHeaders.CONTENT_ENCODING, "gzip"); + } +} diff --git a/commafeed-server/src/test/java/com/commafeed/integration/SecurityIT.java b/commafeed-server/src/test/java/com/commafeed/integration/SecurityIT.java index 77ad98d1..b483fa18 100644 --- a/commafeed-server/src/test/java/com/commafeed/integration/SecurityIT.java +++ b/commafeed-server/src/test/java/com/commafeed/integration/SecurityIT.java @@ -1,139 +1,139 @@ -package com.commafeed.integration; - -import java.net.HttpCookie; -import java.util.List; -import java.util.stream.Collectors; - -import jakarta.ws.rs.core.HttpHeaders; -import jakarta.ws.rs.core.MediaType; - -import org.apache.hc.core5.http.HttpStatus; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import com.commafeed.ExceptionMappers.UnauthorizedResponse; -import com.commafeed.frontend.model.Entries; -import com.commafeed.frontend.model.UserModel; -import com.commafeed.frontend.model.request.MarkRequest; -import com.commafeed.frontend.model.request.ProfileModificationRequest; -import com.commafeed.frontend.model.request.SubscribeRequest; - -import io.quarkus.test.junit.QuarkusTest; -import io.restassured.RestAssured; - -@QuarkusTest -class SecurityIT extends BaseIT { - - @Test - void notLoggedIn() { - UnauthorizedResponse info = RestAssured.given() - .get("rest/user/profile") - .then() - .statusCode(HttpStatus.SC_UNAUTHORIZED) - .extract() - .as(UnauthorizedResponse.class); - Assertions.assertTrue(info.allowRegistrations()); - } - - @Test - void formLogin() { - List cookies = login(); - cookies.forEach(c -> Assertions.assertTrue(c.getMaxAge() > 0)); - - RestAssured.given() - .header(HttpHeaders.COOKIE, cookies.stream().map(HttpCookie::toString).collect(Collectors.joining(";"))) - .get("rest/user/profile") - .then() - .statusCode(HttpStatus.SC_OK); - } - - @Test - void basicAuthLogin() { - RestAssured.given().auth().preemptive().basic("admin", "admin").get("rest/user/profile").then().statusCode(HttpStatus.SC_OK); - } - - @Test - void wrongPassword() { - RestAssured.given() - .auth() - .preemptive() - .basic("admin", "wrong-password") - .get("rest/user/profile") - .then() - .statusCode(HttpStatus.SC_UNAUTHORIZED); - } - - @Test - void missingRole() { - RestAssured.given().auth().preemptive().basic("demo", "demo").get("rest/admin/metrics").then().statusCode(HttpStatus.SC_FORBIDDEN); - } - - @Test - void apiKey() { - // create api key - ProfileModificationRequest req = new ProfileModificationRequest(); - req.setCurrentPassword("admin"); - req.setNewApiKey(true); - RestAssured.given() - .auth() - .preemptive() - .basic("admin", "admin") - .body(req) - .contentType(MediaType.APPLICATION_JSON) - .post("rest/user/profile") - .then() - .statusCode(HttpStatus.SC_OK); - - // fetch api key - String apiKey = RestAssured.given() - .auth() - .preemptive() - .basic("admin", "admin") - .get("rest/user/profile") - .then() - .statusCode(HttpStatus.SC_OK) - .extract() - .as(UserModel.class) - .getApiKey(); - - // subscribe to a feed - SubscribeRequest subscribeRequest = new SubscribeRequest(); - subscribeRequest.setUrl(getFeedUrl()); - subscribeRequest.setTitle("my title for this feed"); - long subscriptionId = RestAssured.given() - .auth() - .preemptive() - .basic("admin", "admin") - .body(subscribeRequest) - .contentType(MediaType.APPLICATION_JSON) - .post("rest/feed/subscribe") - .then() - .statusCode(HttpStatus.SC_OK) - .extract() - .as(Long.class); - - // get entries with api key - Entries entries = RestAssured.given() - .queryParam("id", subscriptionId) - .queryParam("readType", "unread") - .queryParam("apiKey", apiKey) - .get("rest/feed/entries") - .then() - .statusCode(HttpStatus.SC_OK) - .extract() - .as(Entries.class); - Assertions.assertEquals("my title for this feed", entries.getName()); - - // mark entry as read and expect it won't work because it's not a GET request - MarkRequest markRequest = new MarkRequest(); - markRequest.setId("1"); - markRequest.setRead(true); - RestAssured.given() - .body(markRequest) - .contentType(MediaType.APPLICATION_JSON) - .queryParam("apiKey", apiKey) - .post("rest/entry/mark") - .then() - .statusCode(HttpStatus.SC_UNAUTHORIZED); - } -} +package com.commafeed.integration; + +import java.net.HttpCookie; +import java.util.List; +import java.util.stream.Collectors; + +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; + +import org.apache.hc.core5.http.HttpStatus; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import com.commafeed.ExceptionMappers.UnauthorizedResponse; +import com.commafeed.frontend.model.Entries; +import com.commafeed.frontend.model.UserModel; +import com.commafeed.frontend.model.request.MarkRequest; +import com.commafeed.frontend.model.request.ProfileModificationRequest; +import com.commafeed.frontend.model.request.SubscribeRequest; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; + +@QuarkusTest +class SecurityIT extends BaseIT { + + @Test + void notLoggedIn() { + UnauthorizedResponse info = RestAssured.given() + .get("rest/user/profile") + .then() + .statusCode(HttpStatus.SC_UNAUTHORIZED) + .extract() + .as(UnauthorizedResponse.class); + Assertions.assertTrue(info.allowRegistrations()); + } + + @Test + void formLogin() { + List cookies = login(); + cookies.forEach(c -> Assertions.assertTrue(c.getMaxAge() > 0)); + + RestAssured.given() + .header(HttpHeaders.COOKIE, cookies.stream().map(HttpCookie::toString).collect(Collectors.joining(";"))) + .get("rest/user/profile") + .then() + .statusCode(HttpStatus.SC_OK); + } + + @Test + void basicAuthLogin() { + RestAssured.given().auth().preemptive().basic("admin", "admin").get("rest/user/profile").then().statusCode(HttpStatus.SC_OK); + } + + @Test + void wrongPassword() { + RestAssured.given() + .auth() + .preemptive() + .basic("admin", "wrong-password") + .get("rest/user/profile") + .then() + .statusCode(HttpStatus.SC_UNAUTHORIZED); + } + + @Test + void missingRole() { + RestAssured.given().auth().preemptive().basic("demo", "demo").get("rest/admin/metrics").then().statusCode(HttpStatus.SC_FORBIDDEN); + } + + @Test + void apiKey() { + // create api key + ProfileModificationRequest req = new ProfileModificationRequest(); + req.setCurrentPassword("admin"); + req.setNewApiKey(true); + RestAssured.given() + .auth() + .preemptive() + .basic("admin", "admin") + .body(req) + .contentType(MediaType.APPLICATION_JSON) + .post("rest/user/profile") + .then() + .statusCode(HttpStatus.SC_OK); + + // fetch api key + String apiKey = RestAssured.given() + .auth() + .preemptive() + .basic("admin", "admin") + .get("rest/user/profile") + .then() + .statusCode(HttpStatus.SC_OK) + .extract() + .as(UserModel.class) + .getApiKey(); + + // subscribe to a feed + SubscribeRequest subscribeRequest = new SubscribeRequest(); + subscribeRequest.setUrl(getFeedUrl()); + subscribeRequest.setTitle("my title for this feed"); + long subscriptionId = RestAssured.given() + .auth() + .preemptive() + .basic("admin", "admin") + .body(subscribeRequest) + .contentType(MediaType.APPLICATION_JSON) + .post("rest/feed/subscribe") + .then() + .statusCode(HttpStatus.SC_OK) + .extract() + .as(Long.class); + + // get entries with api key + Entries entries = RestAssured.given() + .queryParam("id", subscriptionId) + .queryParam("readType", "unread") + .queryParam("apiKey", apiKey) + .get("rest/feed/entries") + .then() + .statusCode(HttpStatus.SC_OK) + .extract() + .as(Entries.class); + Assertions.assertEquals("my title for this feed", entries.getName()); + + // mark entry as read and expect it won't work because it's not a GET request + MarkRequest markRequest = new MarkRequest(); + markRequest.setId("1"); + markRequest.setRead(true); + RestAssured.given() + .body(markRequest) + .contentType(MediaType.APPLICATION_JSON) + .queryParam("apiKey", apiKey) + .post("rest/entry/mark") + .then() + .statusCode(HttpStatus.SC_UNAUTHORIZED); + } +} diff --git a/commafeed-server/src/test/java/com/commafeed/integration/StaticFilesIT.java b/commafeed-server/src/test/java/com/commafeed/integration/StaticFilesIT.java index e616f5a7..3e451bb1 100644 --- a/commafeed-server/src/test/java/com/commafeed/integration/StaticFilesIT.java +++ b/commafeed-server/src/test/java/com/commafeed/integration/StaticFilesIT.java @@ -1,23 +1,23 @@ -package com.commafeed.integration; - -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import io.quarkus.test.junit.QuarkusTest; -import io.restassured.RestAssured; - -@QuarkusTest -class StaticFilesIT { - - @ParameterizedTest - @ValueSource(strings = { "/", "/openapi.json", "/openapi.yaml" }) - void servedWithoutCache(String path) { - RestAssured.given().when().get(path).then().statusCode(200).header("Cache-Control", "no-cache"); - } - - @ParameterizedTest - @ValueSource(strings = { "/favicon.ico" }) - void servedWithCache(String path) { - RestAssured.given().when().get(path).then().statusCode(200).header("Cache-Control", "public, immutable, max-age=86400"); - } -} +package com.commafeed.integration; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; + +@QuarkusTest +class StaticFilesIT { + + @ParameterizedTest + @ValueSource(strings = { "/", "/openapi.json", "/openapi.yaml" }) + void servedWithoutCache(String path) { + RestAssured.given().when().get(path).then().statusCode(200).header("Cache-Control", "no-cache"); + } + + @ParameterizedTest + @ValueSource(strings = { "/favicon.ico" }) + void servedWithCache(String path) { + RestAssured.given().when().get(path).then().statusCode(200).header("Cache-Control", "public, immutable, max-age=86400"); + } +} diff --git a/commafeed-server/src/test/java/com/commafeed/integration/WebSocketIT.java b/commafeed-server/src/test/java/com/commafeed/integration/WebSocketIT.java index 104bcb18..9c93e951 100644 --- a/commafeed-server/src/test/java/com/commafeed/integration/WebSocketIT.java +++ b/commafeed-server/src/test/java/com/commafeed/integration/WebSocketIT.java @@ -1,161 +1,161 @@ -package com.commafeed.integration; - -import java.io.IOException; -import java.net.HttpCookie; -import java.net.URI; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import java.util.stream.Collectors; - -import jakarta.websocket.ClientEndpointConfig; -import jakarta.websocket.CloseReason; -import jakarta.websocket.ContainerProvider; -import jakarta.websocket.DeploymentException; -import jakarta.websocket.Endpoint; -import jakarta.websocket.EndpointConfig; -import jakarta.websocket.Session; -import jakarta.ws.rs.core.HttpHeaders; -import jakarta.ws.rs.core.MediaType; - -import org.apache.hc.core5.http.HttpStatus; -import org.awaitility.Awaitility; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import com.commafeed.frontend.model.request.FeedModificationRequest; - -import io.quarkus.test.junit.QuarkusTest; -import io.restassured.RestAssured; -import lombok.extern.slf4j.Slf4j; - -@QuarkusTest -@Slf4j -class WebSocketIT extends BaseIT { - - @BeforeEach - void setup() { - RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); - } - - @AfterEach - void tearDown() { - RestAssured.reset(); - } - - @Test - void sessionClosedIfNotLoggedIn() throws DeploymentException, IOException { - AtomicBoolean connected = new AtomicBoolean(); - AtomicReference closeReasonRef = new AtomicReference<>(); - try (Session session = ContainerProvider.getWebSocketContainer().connectToServer(new Endpoint() { - @Override - public void onOpen(Session session, EndpointConfig config) { - connected.set(true); - } - - @Override - public void onClose(Session session, CloseReason closeReason) { - closeReasonRef.set(closeReason); - } - }, buildConfig(List.of()), URI.create(getWebSocketUrl()))) { - Awaitility.await().atMost(15, TimeUnit.SECONDS).untilTrue(connected); - log.info("connected to {}", session.getRequestURI()); - - Awaitility.await().atMost(15, TimeUnit.SECONDS).until(() -> closeReasonRef.get() != null); - } - } - - @Test - void subscribeAndGetsNotified() throws DeploymentException, IOException { - List cookies = login(); - - AtomicBoolean connected = new AtomicBoolean(); - AtomicReference messageRef = new AtomicReference<>(); - try (Session session = ContainerProvider.getWebSocketContainer().connectToServer(new Endpoint() { - @Override - public void onOpen(Session session, EndpointConfig config) { - session.addMessageHandler(String.class, messageRef::set); - connected.set(true); - } - }, buildConfig(cookies), URI.create(getWebSocketUrl()))) { - Awaitility.await().atMost(15, TimeUnit.SECONDS).untilTrue(connected); - log.info("connected to {}", session.getRequestURI()); - - Long subscriptionId = subscribe(getFeedUrl()); - - Awaitility.await().atMost(15, TimeUnit.SECONDS).until(() -> messageRef.get() != null); - Assertions.assertEquals("new-feed-entries:" + subscriptionId + ":2", messageRef.get()); - } - } - - @Test - void notNotifiedForFilteredEntries() throws DeploymentException, IOException { - List cookies = login(); - Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); - - FeedModificationRequest req = new FeedModificationRequest(); - req.setId(subscriptionId); - req.setName("feed-name"); - req.setFilter("!title.contains('item 4')"); - RestAssured.given().body(req).contentType(MediaType.APPLICATION_JSON).post("rest/feed/modify").then().statusCode(HttpStatus.SC_OK); - - AtomicBoolean connected = new AtomicBoolean(); - AtomicReference messageRef = new AtomicReference<>(); - try (Session session = ContainerProvider.getWebSocketContainer().connectToServer(new Endpoint() { - @Override - public void onOpen(Session session, EndpointConfig config) { - session.addMessageHandler(String.class, messageRef::set); - connected.set(true); - } - }, buildConfig(cookies), URI.create(getWebSocketUrl()))) { - Awaitility.await().atMost(15, TimeUnit.SECONDS).untilTrue(connected); - log.info("connected to {}", session.getRequestURI()); - - feedNowReturnsMoreEntries(); - forceRefreshAllFeeds(); - - Awaitility.await().atMost(15, TimeUnit.SECONDS).until(() -> messageRef.get() != null); - Assertions.assertEquals("new-feed-entries:" + subscriptionId + ":1", messageRef.get()); - } - - } - - @Test - void pingPong() throws DeploymentException, IOException { - List cookies = login(); - - AtomicBoolean connected = new AtomicBoolean(); - AtomicReference messageRef = new AtomicReference<>(); - try (Session session = ContainerProvider.getWebSocketContainer().connectToServer(new Endpoint() { - @Override - public void onOpen(Session session, EndpointConfig config) { - session.addMessageHandler(String.class, messageRef::set); - connected.set(true); - } - }, buildConfig(cookies), URI.create(getWebSocketUrl()))) { - Awaitility.await().atMost(15, TimeUnit.SECONDS).untilTrue(connected); - log.info("connected to {}", session.getRequestURI()); - - session.getAsyncRemote().sendText("ping"); - - Awaitility.await().atMost(15, TimeUnit.SECONDS).until(() -> messageRef.get() != null); - Assertions.assertEquals("pong", messageRef.get()); - } - } - - private ClientEndpointConfig buildConfig(List cookies) { - return ClientEndpointConfig.Builder.create().configurator(new ClientEndpointConfig.Configurator() { - @Override - public void beforeRequest(Map> headers) { - headers.put(HttpHeaders.COOKIE, - Collections.singletonList(cookies.stream().map(HttpCookie::toString).collect(Collectors.joining(";")))); - } - }).build(); - } - -} +package com.commafeed.integration; + +import java.io.IOException; +import java.net.HttpCookie; +import java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import jakarta.websocket.ClientEndpointConfig; +import jakarta.websocket.CloseReason; +import jakarta.websocket.ContainerProvider; +import jakarta.websocket.DeploymentException; +import jakarta.websocket.Endpoint; +import jakarta.websocket.EndpointConfig; +import jakarta.websocket.Session; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; + +import org.apache.hc.core5.http.HttpStatus; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.commafeed.frontend.model.request.FeedModificationRequest; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import lombok.extern.slf4j.Slf4j; + +@QuarkusTest +@Slf4j +class WebSocketIT extends BaseIT { + + @BeforeEach + void setup() { + RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); + } + + @AfterEach + void tearDown() { + RestAssured.reset(); + } + + @Test + void sessionClosedIfNotLoggedIn() throws DeploymentException, IOException { + AtomicBoolean connected = new AtomicBoolean(); + AtomicReference closeReasonRef = new AtomicReference<>(); + try (Session session = ContainerProvider.getWebSocketContainer().connectToServer(new Endpoint() { + @Override + public void onOpen(Session session, EndpointConfig config) { + connected.set(true); + } + + @Override + public void onClose(Session session, CloseReason closeReason) { + closeReasonRef.set(closeReason); + } + }, buildConfig(List.of()), URI.create(getWebSocketUrl()))) { + Awaitility.await().atMost(15, TimeUnit.SECONDS).untilTrue(connected); + log.info("connected to {}", session.getRequestURI()); + + Awaitility.await().atMost(15, TimeUnit.SECONDS).until(() -> closeReasonRef.get() != null); + } + } + + @Test + void subscribeAndGetsNotified() throws DeploymentException, IOException { + List cookies = login(); + + AtomicBoolean connected = new AtomicBoolean(); + AtomicReference messageRef = new AtomicReference<>(); + try (Session session = ContainerProvider.getWebSocketContainer().connectToServer(new Endpoint() { + @Override + public void onOpen(Session session, EndpointConfig config) { + session.addMessageHandler(String.class, messageRef::set); + connected.set(true); + } + }, buildConfig(cookies), URI.create(getWebSocketUrl()))) { + Awaitility.await().atMost(15, TimeUnit.SECONDS).untilTrue(connected); + log.info("connected to {}", session.getRequestURI()); + + Long subscriptionId = subscribe(getFeedUrl()); + + Awaitility.await().atMost(15, TimeUnit.SECONDS).until(() -> messageRef.get() != null); + Assertions.assertEquals("new-feed-entries:" + subscriptionId + ":2", messageRef.get()); + } + } + + @Test + void notNotifiedForFilteredEntries() throws DeploymentException, IOException { + List cookies = login(); + Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); + + FeedModificationRequest req = new FeedModificationRequest(); + req.setId(subscriptionId); + req.setName("feed-name"); + req.setFilter("!title.contains('item 4')"); + RestAssured.given().body(req).contentType(MediaType.APPLICATION_JSON).post("rest/feed/modify").then().statusCode(HttpStatus.SC_OK); + + AtomicBoolean connected = new AtomicBoolean(); + AtomicReference messageRef = new AtomicReference<>(); + try (Session session = ContainerProvider.getWebSocketContainer().connectToServer(new Endpoint() { + @Override + public void onOpen(Session session, EndpointConfig config) { + session.addMessageHandler(String.class, messageRef::set); + connected.set(true); + } + }, buildConfig(cookies), URI.create(getWebSocketUrl()))) { + Awaitility.await().atMost(15, TimeUnit.SECONDS).untilTrue(connected); + log.info("connected to {}", session.getRequestURI()); + + feedNowReturnsMoreEntries(); + forceRefreshAllFeeds(); + + Awaitility.await().atMost(15, TimeUnit.SECONDS).until(() -> messageRef.get() != null); + Assertions.assertEquals("new-feed-entries:" + subscriptionId + ":1", messageRef.get()); + } + + } + + @Test + void pingPong() throws DeploymentException, IOException { + List cookies = login(); + + AtomicBoolean connected = new AtomicBoolean(); + AtomicReference messageRef = new AtomicReference<>(); + try (Session session = ContainerProvider.getWebSocketContainer().connectToServer(new Endpoint() { + @Override + public void onOpen(Session session, EndpointConfig config) { + session.addMessageHandler(String.class, messageRef::set); + connected.set(true); + } + }, buildConfig(cookies), URI.create(getWebSocketUrl()))) { + Awaitility.await().atMost(15, TimeUnit.SECONDS).untilTrue(connected); + log.info("connected to {}", session.getRequestURI()); + + session.getAsyncRemote().sendText("ping"); + + Awaitility.await().atMost(15, TimeUnit.SECONDS).until(() -> messageRef.get() != null); + Assertions.assertEquals("pong", messageRef.get()); + } + } + + private ClientEndpointConfig buildConfig(List cookies) { + return ClientEndpointConfig.Builder.create().configurator(new ClientEndpointConfig.Configurator() { + @Override + public void beforeRequest(Map> headers) { + headers.put(HttpHeaders.COOKIE, + Collections.singletonList(cookies.stream().map(HttpCookie::toString).collect(Collectors.joining(";")))); + } + }).build(); + } + +} diff --git a/commafeed-server/src/test/java/com/commafeed/integration/rest/AdminIT.java b/commafeed-server/src/test/java/com/commafeed/integration/rest/AdminIT.java index 2e46b1b7..e852c340 100644 --- a/commafeed-server/src/test/java/com/commafeed/integration/rest/AdminIT.java +++ b/commafeed-server/src/test/java/com/commafeed/integration/rest/AdminIT.java @@ -1,102 +1,102 @@ -package com.commafeed.integration.rest; - -import java.util.List; - -import jakarta.ws.rs.core.MediaType; - -import org.apache.hc.core5.http.HttpStatus; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import com.commafeed.backend.model.User; -import com.commafeed.frontend.model.UserModel; -import com.commafeed.frontend.model.request.IDRequest; -import com.commafeed.integration.BaseIT; - -import io.quarkus.test.junit.QuarkusTest; -import io.restassured.RestAssured; - -@QuarkusTest -class AdminIT extends BaseIT { - - @BeforeEach - void setup() { - RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); - } - - @AfterEach - void cleanup() { - RestAssured.reset(); - } - - @Nested - class Users { - @Test - void saveModifyAndDeleteNewUser() { - List existingUsers = getAllUsers(); - - createUser(); - Assertions.assertEquals(existingUsers.size() + 1, getAllUsers().size()); - - modifyUser(); - Assertions.assertEquals(existingUsers.size() + 1, getAllUsers().size()); - - deleteUser(); - Assertions.assertEquals(existingUsers.size(), getAllUsers().size()); - } - - private void createUser() { - User user = new User(); - user.setName("test"); - user.setPassword("test".getBytes()); - user.setEmail("test@test.com"); - RestAssured.given() - .body(user) - .contentType(MediaType.APPLICATION_JSON) - .post("rest/admin/user/save") - .then() - .statusCode(HttpStatus.SC_OK); - } - - private void modifyUser() { - List existingUsers = getAllUsers(); - UserModel user = existingUsers.stream() - .filter(u -> u.getName().equals("test")) - .findFirst() - .orElseThrow(() -> new NullPointerException("User not found")); - user.setEmail("new-email@provider.com"); - RestAssured.given() - .body(user) - .contentType(MediaType.APPLICATION_JSON) - .post("rest/admin/user/save") - .then() - .statusCode(HttpStatus.SC_OK); - } - - private void deleteUser() { - List existingUsers = getAllUsers(); - UserModel user = existingUsers.stream() - .filter(u -> u.getName().equals("test")) - .findFirst() - .orElseThrow(() -> new NullPointerException("User not found")); - - IDRequest req = new IDRequest(); - req.setId(user.getId()); - RestAssured.given() - .body(req) - .contentType(MediaType.APPLICATION_JSON) - .post("rest/admin/user/delete") - .then() - .statusCode(HttpStatus.SC_OK); - } - - private List getAllUsers() { - return List.of( - RestAssured.given().get("rest/admin/user/getAll").then().statusCode(HttpStatus.SC_OK).extract().as(UserModel[].class)); - } - } - -} +package com.commafeed.integration.rest; + +import java.util.List; + +import jakarta.ws.rs.core.MediaType; + +import org.apache.hc.core5.http.HttpStatus; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.commafeed.backend.model.User; +import com.commafeed.frontend.model.UserModel; +import com.commafeed.frontend.model.request.IDRequest; +import com.commafeed.integration.BaseIT; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; + +@QuarkusTest +class AdminIT extends BaseIT { + + @BeforeEach + void setup() { + RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); + } + + @AfterEach + void cleanup() { + RestAssured.reset(); + } + + @Nested + class Users { + @Test + void saveModifyAndDeleteNewUser() { + List existingUsers = getAllUsers(); + + createUser(); + Assertions.assertEquals(existingUsers.size() + 1, getAllUsers().size()); + + modifyUser(); + Assertions.assertEquals(existingUsers.size() + 1, getAllUsers().size()); + + deleteUser(); + Assertions.assertEquals(existingUsers.size(), getAllUsers().size()); + } + + private void createUser() { + User user = new User(); + user.setName("test"); + user.setPassword("test".getBytes()); + user.setEmail("test@test.com"); + RestAssured.given() + .body(user) + .contentType(MediaType.APPLICATION_JSON) + .post("rest/admin/user/save") + .then() + .statusCode(HttpStatus.SC_OK); + } + + private void modifyUser() { + List existingUsers = getAllUsers(); + UserModel user = existingUsers.stream() + .filter(u -> u.getName().equals("test")) + .findFirst() + .orElseThrow(() -> new NullPointerException("User not found")); + user.setEmail("new-email@provider.com"); + RestAssured.given() + .body(user) + .contentType(MediaType.APPLICATION_JSON) + .post("rest/admin/user/save") + .then() + .statusCode(HttpStatus.SC_OK); + } + + private void deleteUser() { + List existingUsers = getAllUsers(); + UserModel user = existingUsers.stream() + .filter(u -> u.getName().equals("test")) + .findFirst() + .orElseThrow(() -> new NullPointerException("User not found")); + + IDRequest req = new IDRequest(); + req.setId(user.getId()); + RestAssured.given() + .body(req) + .contentType(MediaType.APPLICATION_JSON) + .post("rest/admin/user/delete") + .then() + .statusCode(HttpStatus.SC_OK); + } + + private List getAllUsers() { + return List.of( + RestAssured.given().get("rest/admin/user/getAll").then().statusCode(HttpStatus.SC_OK).extract().as(UserModel[].class)); + } + } + +} diff --git a/commafeed-server/src/test/java/com/commafeed/integration/rest/FeedIT.java b/commafeed-server/src/test/java/com/commafeed/integration/rest/FeedIT.java index 07595934..0883e53a 100644 --- a/commafeed-server/src/test/java/com/commafeed/integration/rest/FeedIT.java +++ b/commafeed-server/src/test/java/com/commafeed/integration/rest/FeedIT.java @@ -1,266 +1,266 @@ -package com.commafeed.integration.rest; - -import java.io.IOException; -import java.io.InputStream; -import java.time.Duration; -import java.time.Instant; -import java.time.LocalDate; -import java.time.ZoneOffset; -import java.util.Objects; - -import jakarta.ws.rs.core.HttpHeaders; -import jakarta.ws.rs.core.MediaType; - -import org.apache.commons.io.IOUtils; -import org.apache.hc.core5.http.HttpStatus; -import org.awaitility.Awaitility; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import com.commafeed.frontend.model.Entry; -import com.commafeed.frontend.model.FeedInfo; -import com.commafeed.frontend.model.Subscription; -import com.commafeed.frontend.model.request.FeedInfoRequest; -import com.commafeed.frontend.model.request.FeedModificationRequest; -import com.commafeed.frontend.model.request.IDRequest; -import com.commafeed.frontend.model.request.MarkRequest; -import com.commafeed.integration.BaseIT; - -import io.quarkus.test.junit.QuarkusTest; -import io.restassured.RestAssured; - -@QuarkusTest -class FeedIT extends BaseIT { - - @BeforeEach - void setup() { - RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); - } - - @AfterEach - void cleanup() { - RestAssured.reset(); - } - - @Nested - class Fetch { - @Test - void fetchFeed() { - FeedInfoRequest req = new FeedInfoRequest(); - req.setUrl(getFeedUrl()); - - FeedInfo feedInfo = RestAssured.given() - .body(req) - .contentType(MediaType.APPLICATION_JSON) - .post("rest/feed/fetch") - .then() - .statusCode(HttpStatus.SC_OK) - .extract() - .as(FeedInfo.class); - Assertions.assertEquals("CommaFeed test feed", feedInfo.getTitle()); - Assertions.assertEquals(getFeedUrl(), feedInfo.getUrl()); - } - } - - @Nested - class Subscribe { - @Test - void subscribeAndReadEntries() { - long subscriptionId = subscribe(getFeedUrl()); - Awaitility.await().atMost(Duration.ofSeconds(15)).until(() -> getFeedEntries(subscriptionId), e -> e.getEntries().size() == 2); - } - - @Test - void subscribeFromUrl() { - RestAssured.given() - .queryParam("url", getFeedUrl()) - .redirects() - .follow(false) - .get("rest/feed/subscribe") - .then() - .statusCode(HttpStatus.SC_TEMPORARY_REDIRECT); - } - - @Test - void unsubscribeFromUnknownFeed() { - Assertions.assertEquals(HttpStatus.SC_NOT_FOUND, unsubsribe(1L)); - } - - @Test - void unsubscribeFromKnownFeed() { - long subscriptionId = subscribe(getFeedUrl()); - Assertions.assertEquals(HttpStatus.SC_OK, unsubsribe(subscriptionId)); - } - - private int unsubsribe(long subscriptionId) { - IDRequest request = new IDRequest(); - request.setId(subscriptionId); - - return RestAssured.given() - .body(request) - .contentType(MediaType.APPLICATION_JSON) - .post("rest/feed/unsubscribe") - .then() - .extract() - .statusCode(); - } - } - - @Nested - class Mark { - @Test - void markWithoutDates() { - long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); - markFeedEntries(subscriptionId, null, null); - Assertions.assertTrue(getFeedEntries(subscriptionId).getEntries().stream().allMatch(Entry::isRead)); - } - - @Test - void markOlderThan() { - long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); - markFeedEntries(subscriptionId, LocalDate.of(2023, 12, 28).atStartOfDay().toInstant(ZoneOffset.UTC), null); - Assertions.assertEquals(1, getFeedEntries(subscriptionId).getEntries().stream().filter(Entry::isRead).count()); - } - - @Test - void markInsertedBeforeBeforeSubscription() { - // mariadb/mysql timestamp precision is 1 second - Instant threshold = Instant.now().minus(Duration.ofSeconds(1)); - - long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); - markFeedEntries(subscriptionId, null, threshold); - Assertions.assertTrue(getFeedEntries(subscriptionId).getEntries().stream().noneMatch(Entry::isRead)); - } - - @Test - void markInsertedBeforeAfterSubscription() { - long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); - - // mariadb/mysql timestamp precision is 1 second - Instant threshold = Instant.now().plus(Duration.ofSeconds(1)); - - markFeedEntries(subscriptionId, null, threshold); - Assertions.assertTrue(getFeedEntries(subscriptionId).getEntries().stream().allMatch(Entry::isRead)); - } - - private void markFeedEntries(long subscriptionId, Instant olderThan, Instant insertedBefore) { - MarkRequest request = new MarkRequest(); - request.setId(String.valueOf(subscriptionId)); - request.setOlderThan(olderThan == null ? null : olderThan.toEpochMilli()); - request.setInsertedBefore(insertedBefore == null ? null : insertedBefore.toEpochMilli()); - - RestAssured.given() - .body(request) - .contentType(MediaType.APPLICATION_JSON) - .post("rest/feed/mark") - .then() - .statusCode(HttpStatus.SC_OK); - } - } - - @Nested - class Refresh { - @Test - void refresh() { - Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); - - // mariadb/mysql timestamp precision is 1 second - Instant threshold = Instant.now().minus(Duration.ofSeconds(1)); - - IDRequest request = new IDRequest(); - request.setId(subscriptionId); - RestAssured.given() - .body(request) - .contentType(MediaType.APPLICATION_JSON) - .post("rest/feed/refresh") - .then() - .statusCode(HttpStatus.SC_OK); - - Awaitility.await() - .atMost(Duration.ofSeconds(15)) - .until(() -> getSubscription(subscriptionId), f -> f.getLastRefresh().isAfter(threshold)); - } - - @Test - void refreshAll() { - Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); - - // mariadb/mysql timestamp precision is 1 second - Instant threshold = Instant.now().minus(Duration.ofSeconds(1)); - Assertions.assertEquals(HttpStatus.SC_OK, forceRefreshAllFeeds()); - - Awaitility.await() - .atMost(Duration.ofSeconds(15)) - .until(() -> getSubscription(subscriptionId), f -> f.getLastRefresh().isAfter(threshold)); - - Assertions.assertEquals(HttpStatus.SC_TOO_MANY_REQUESTS, forceRefreshAllFeeds()); - } - } - - @Nested - class Modify { - @Test - void modify() { - Long subscriptionId = subscribe(getFeedUrl()); - - Subscription subscription = getSubscription(subscriptionId); - - FeedModificationRequest req = new FeedModificationRequest(); - req.setId(subscriptionId); - req.setName("new name"); - req.setCategoryId(subscription.getCategoryId()); - RestAssured.given() - .body(req) - .contentType(MediaType.APPLICATION_JSON) - .post("rest/feed/modify") - .then() - .statusCode(HttpStatus.SC_OK); - - subscription = getSubscription(subscriptionId); - Assertions.assertEquals("new name", subscription.getName()); - } - } - - @Nested - class Favicon { - @Test - void favicon() throws IOException { - Long subscriptionId = subscribe(getFeedUrl()); - - byte[] icon = RestAssured.given() - .get("rest/feed/favicon/{id}", subscriptionId) - .then() - .statusCode(HttpStatus.SC_OK) - .header(HttpHeaders.CACHE_CONTROL, "max-age=2592000") - .extract() - .response() - .asByteArray(); - byte[] defaultFavicon = IOUtils.toByteArray(Objects.requireNonNull(getClass().getResource("/images/default_favicon.gif"))); - Assertions.assertArrayEquals(defaultFavicon, icon); - } - } - - @Nested - class Opml { - @Test - void importExportOpml() { - importOpml(); - String opml = RestAssured.given().get("rest/feed/export").then().statusCode(HttpStatus.SC_OK).extract().asString(); - Assertions.assertTrue(opml.contains("admin subscriptions in CommaFeed")); - } - - void importOpml() { - InputStream stream = Objects.requireNonNull(getClass().getResourceAsStream("/opml/opml_v2.0.xml")); - - RestAssured.given() - .multiPart("file", "opml_v2.0.xml", stream, MediaType.MULTIPART_FORM_DATA) - .post("rest/feed/import") - .then() - .statusCode(HttpStatus.SC_OK); - } - } - -} +package com.commafeed.integration.rest; + +import java.io.IOException; +import java.io.InputStream; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.Objects; + +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; + +import org.apache.commons.io.IOUtils; +import org.apache.hc.core5.http.HttpStatus; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.commafeed.frontend.model.Entry; +import com.commafeed.frontend.model.FeedInfo; +import com.commafeed.frontend.model.Subscription; +import com.commafeed.frontend.model.request.FeedInfoRequest; +import com.commafeed.frontend.model.request.FeedModificationRequest; +import com.commafeed.frontend.model.request.IDRequest; +import com.commafeed.frontend.model.request.MarkRequest; +import com.commafeed.integration.BaseIT; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; + +@QuarkusTest +class FeedIT extends BaseIT { + + @BeforeEach + void setup() { + RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); + } + + @AfterEach + void cleanup() { + RestAssured.reset(); + } + + @Nested + class Fetch { + @Test + void fetchFeed() { + FeedInfoRequest req = new FeedInfoRequest(); + req.setUrl(getFeedUrl()); + + FeedInfo feedInfo = RestAssured.given() + .body(req) + .contentType(MediaType.APPLICATION_JSON) + .post("rest/feed/fetch") + .then() + .statusCode(HttpStatus.SC_OK) + .extract() + .as(FeedInfo.class); + Assertions.assertEquals("CommaFeed test feed", feedInfo.getTitle()); + Assertions.assertEquals(getFeedUrl(), feedInfo.getUrl()); + } + } + + @Nested + class Subscribe { + @Test + void subscribeAndReadEntries() { + long subscriptionId = subscribe(getFeedUrl()); + Awaitility.await().atMost(Duration.ofSeconds(15)).until(() -> getFeedEntries(subscriptionId), e -> e.getEntries().size() == 2); + } + + @Test + void subscribeFromUrl() { + RestAssured.given() + .queryParam("url", getFeedUrl()) + .redirects() + .follow(false) + .get("rest/feed/subscribe") + .then() + .statusCode(HttpStatus.SC_TEMPORARY_REDIRECT); + } + + @Test + void unsubscribeFromUnknownFeed() { + Assertions.assertEquals(HttpStatus.SC_NOT_FOUND, unsubsribe(1L)); + } + + @Test + void unsubscribeFromKnownFeed() { + long subscriptionId = subscribe(getFeedUrl()); + Assertions.assertEquals(HttpStatus.SC_OK, unsubsribe(subscriptionId)); + } + + private int unsubsribe(long subscriptionId) { + IDRequest request = new IDRequest(); + request.setId(subscriptionId); + + return RestAssured.given() + .body(request) + .contentType(MediaType.APPLICATION_JSON) + .post("rest/feed/unsubscribe") + .then() + .extract() + .statusCode(); + } + } + + @Nested + class Mark { + @Test + void markWithoutDates() { + long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); + markFeedEntries(subscriptionId, null, null); + Assertions.assertTrue(getFeedEntries(subscriptionId).getEntries().stream().allMatch(Entry::isRead)); + } + + @Test + void markOlderThan() { + long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); + markFeedEntries(subscriptionId, LocalDate.of(2023, 12, 28).atStartOfDay().toInstant(ZoneOffset.UTC), null); + Assertions.assertEquals(1, getFeedEntries(subscriptionId).getEntries().stream().filter(Entry::isRead).count()); + } + + @Test + void markInsertedBeforeBeforeSubscription() { + // mariadb/mysql timestamp precision is 1 second + Instant threshold = Instant.now().minus(Duration.ofSeconds(1)); + + long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); + markFeedEntries(subscriptionId, null, threshold); + Assertions.assertTrue(getFeedEntries(subscriptionId).getEntries().stream().noneMatch(Entry::isRead)); + } + + @Test + void markInsertedBeforeAfterSubscription() { + long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); + + // mariadb/mysql timestamp precision is 1 second + Instant threshold = Instant.now().plus(Duration.ofSeconds(1)); + + markFeedEntries(subscriptionId, null, threshold); + Assertions.assertTrue(getFeedEntries(subscriptionId).getEntries().stream().allMatch(Entry::isRead)); + } + + private void markFeedEntries(long subscriptionId, Instant olderThan, Instant insertedBefore) { + MarkRequest request = new MarkRequest(); + request.setId(String.valueOf(subscriptionId)); + request.setOlderThan(olderThan == null ? null : olderThan.toEpochMilli()); + request.setInsertedBefore(insertedBefore == null ? null : insertedBefore.toEpochMilli()); + + RestAssured.given() + .body(request) + .contentType(MediaType.APPLICATION_JSON) + .post("rest/feed/mark") + .then() + .statusCode(HttpStatus.SC_OK); + } + } + + @Nested + class Refresh { + @Test + void refresh() { + Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); + + // mariadb/mysql timestamp precision is 1 second + Instant threshold = Instant.now().minus(Duration.ofSeconds(1)); + + IDRequest request = new IDRequest(); + request.setId(subscriptionId); + RestAssured.given() + .body(request) + .contentType(MediaType.APPLICATION_JSON) + .post("rest/feed/refresh") + .then() + .statusCode(HttpStatus.SC_OK); + + Awaitility.await() + .atMost(Duration.ofSeconds(15)) + .until(() -> getSubscription(subscriptionId), f -> f.getLastRefresh().isAfter(threshold)); + } + + @Test + void refreshAll() { + Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); + + // mariadb/mysql timestamp precision is 1 second + Instant threshold = Instant.now().minus(Duration.ofSeconds(1)); + Assertions.assertEquals(HttpStatus.SC_OK, forceRefreshAllFeeds()); + + Awaitility.await() + .atMost(Duration.ofSeconds(15)) + .until(() -> getSubscription(subscriptionId), f -> f.getLastRefresh().isAfter(threshold)); + + Assertions.assertEquals(HttpStatus.SC_TOO_MANY_REQUESTS, forceRefreshAllFeeds()); + } + } + + @Nested + class Modify { + @Test + void modify() { + Long subscriptionId = subscribe(getFeedUrl()); + + Subscription subscription = getSubscription(subscriptionId); + + FeedModificationRequest req = new FeedModificationRequest(); + req.setId(subscriptionId); + req.setName("new name"); + req.setCategoryId(subscription.getCategoryId()); + RestAssured.given() + .body(req) + .contentType(MediaType.APPLICATION_JSON) + .post("rest/feed/modify") + .then() + .statusCode(HttpStatus.SC_OK); + + subscription = getSubscription(subscriptionId); + Assertions.assertEquals("new name", subscription.getName()); + } + } + + @Nested + class Favicon { + @Test + void favicon() throws IOException { + Long subscriptionId = subscribe(getFeedUrl()); + + byte[] icon = RestAssured.given() + .get("rest/feed/favicon/{id}", subscriptionId) + .then() + .statusCode(HttpStatus.SC_OK) + .header(HttpHeaders.CACHE_CONTROL, "max-age=2592000") + .extract() + .response() + .asByteArray(); + byte[] defaultFavicon = IOUtils.toByteArray(Objects.requireNonNull(getClass().getResource("/images/default_favicon.gif"))); + Assertions.assertArrayEquals(defaultFavicon, icon); + } + } + + @Nested + class Opml { + @Test + void importExportOpml() { + importOpml(); + String opml = RestAssured.given().get("rest/feed/export").then().statusCode(HttpStatus.SC_OK).extract().asString(); + Assertions.assertTrue(opml.contains("admin subscriptions in CommaFeed")); + } + + void importOpml() { + InputStream stream = Objects.requireNonNull(getClass().getResourceAsStream("/opml/opml_v2.0.xml")); + + RestAssured.given() + .multiPart("file", "opml_v2.0.xml", stream, MediaType.MULTIPART_FORM_DATA) + .post("rest/feed/import") + .then() + .statusCode(HttpStatus.SC_OK); + } + } + +} diff --git a/commafeed-server/src/test/java/com/commafeed/integration/rest/FeverIT.java b/commafeed-server/src/test/java/com/commafeed/integration/rest/FeverIT.java index 62280b19..d17a8850 100644 --- a/commafeed-server/src/test/java/com/commafeed/integration/rest/FeverIT.java +++ b/commafeed-server/src/test/java/com/commafeed/integration/rest/FeverIT.java @@ -1,89 +1,89 @@ -package com.commafeed.integration.rest; - -import jakarta.ws.rs.core.MediaType; - -import org.apache.hc.core5.http.HttpStatus; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import com.commafeed.backend.Digests; -import com.commafeed.frontend.model.UserModel; -import com.commafeed.frontend.model.request.ProfileModificationRequest; -import com.commafeed.frontend.resource.fever.FeverResponse; -import com.commafeed.integration.BaseIT; - -import io.quarkus.test.junit.QuarkusTest; -import io.restassured.RestAssured; - -@QuarkusTest -class FeverIT extends BaseIT { - - private Long userId; - private String apiKey; - - @BeforeEach - void setup() { - RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); - - // create api key - ProfileModificationRequest req = new ProfileModificationRequest(); - req.setCurrentPassword("admin"); - req.setNewApiKey(true); - RestAssured.given().body(req).contentType(MediaType.APPLICATION_JSON).post("rest/user/profile").then().statusCode(HttpStatus.SC_OK); - - // retrieve api key - UserModel user = RestAssured.given().get("rest/user/profile").then().statusCode(HttpStatus.SC_OK).extract().as(UserModel.class); - this.apiKey = user.getApiKey(); - this.userId = user.getId(); - } - - @AfterEach - void cleanup() { - RestAssured.reset(); - } - - @Test - void invalidApiKey() { - FeverResponse response = fetch("feeds", "invalid-key"); - Assertions.assertFalse(response.isAuth()); - } - - @Test - void validApiKey() { - FeverResponse response = fetch("feeds", apiKey); - Assertions.assertTrue(response.isAuth()); - } - - @Test - void feeds() { - subscribe(getFeedUrl()); - FeverResponse feverResponse = fetch("feeds"); - Assertions.assertEquals(1, feverResponse.getFeeds().size()); - } - - @Test - void unreadEntries() { - subscribeAndWaitForEntries(getFeedUrl()); - FeverResponse feverResponse = fetch("unread_item_ids"); - Assertions.assertEquals(2, feverResponse.getUnreadItemIds().size()); - } - - private FeverResponse fetch(String what) { - return fetch(what, apiKey); - } - - private FeverResponse fetch(String what, String apiKey) { - return RestAssured.given() - .auth() - .none() - .formParam("api_key", Digests.md5Hex("admin:" + apiKey)) - .formParam(what, 1) - .post("rest/fever/user/{userId}", userId) - .then() - .statusCode(HttpStatus.SC_OK) - .extract() - .as(FeverResponse.class); - } -} +package com.commafeed.integration.rest; + +import jakarta.ws.rs.core.MediaType; + +import org.apache.hc.core5.http.HttpStatus; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.commafeed.backend.Digests; +import com.commafeed.frontend.model.UserModel; +import com.commafeed.frontend.model.request.ProfileModificationRequest; +import com.commafeed.frontend.resource.fever.FeverResponse; +import com.commafeed.integration.BaseIT; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; + +@QuarkusTest +class FeverIT extends BaseIT { + + private Long userId; + private String apiKey; + + @BeforeEach + void setup() { + RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); + + // create api key + ProfileModificationRequest req = new ProfileModificationRequest(); + req.setCurrentPassword("admin"); + req.setNewApiKey(true); + RestAssured.given().body(req).contentType(MediaType.APPLICATION_JSON).post("rest/user/profile").then().statusCode(HttpStatus.SC_OK); + + // retrieve api key + UserModel user = RestAssured.given().get("rest/user/profile").then().statusCode(HttpStatus.SC_OK).extract().as(UserModel.class); + this.apiKey = user.getApiKey(); + this.userId = user.getId(); + } + + @AfterEach + void cleanup() { + RestAssured.reset(); + } + + @Test + void invalidApiKey() { + FeverResponse response = fetch("feeds", "invalid-key"); + Assertions.assertFalse(response.isAuth()); + } + + @Test + void validApiKey() { + FeverResponse response = fetch("feeds", apiKey); + Assertions.assertTrue(response.isAuth()); + } + + @Test + void feeds() { + subscribe(getFeedUrl()); + FeverResponse feverResponse = fetch("feeds"); + Assertions.assertEquals(1, feverResponse.getFeeds().size()); + } + + @Test + void unreadEntries() { + subscribeAndWaitForEntries(getFeedUrl()); + FeverResponse feverResponse = fetch("unread_item_ids"); + Assertions.assertEquals(2, feverResponse.getUnreadItemIds().size()); + } + + private FeverResponse fetch(String what) { + return fetch(what, apiKey); + } + + private FeverResponse fetch(String what, String apiKey) { + return RestAssured.given() + .auth() + .none() + .formParam("api_key", Digests.md5Hex("admin:" + apiKey)) + .formParam(what, 1) + .post("rest/fever/user/{userId}", userId) + .then() + .statusCode(HttpStatus.SC_OK) + .extract() + .as(FeverResponse.class); + } +} diff --git a/commafeed-server/src/test/java/com/commafeed/integration/rest/ServerIT.java b/commafeed-server/src/test/java/com/commafeed/integration/rest/ServerIT.java index de172808..af75a5eb 100644 --- a/commafeed-server/src/test/java/com/commafeed/integration/rest/ServerIT.java +++ b/commafeed-server/src/test/java/com/commafeed/integration/rest/ServerIT.java @@ -1,27 +1,27 @@ -package com.commafeed.integration.rest; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import com.commafeed.frontend.model.ServerInfo; -import com.commafeed.integration.BaseIT; - -import io.quarkus.test.junit.QuarkusTest; -import io.restassured.RestAssured; - -@QuarkusTest -class ServerIT extends BaseIT { - - @Test - void getServerInfos() { - ServerInfo serverInfos = RestAssured.given().get("/rest/server/get").then().statusCode(200).extract().as(ServerInfo.class); - Assertions.assertTrue(serverInfos.isAllowRegistrations()); - Assertions.assertTrue(serverInfos.isSmtpEnabled()); - Assertions.assertTrue(serverInfos.isDemoAccountEnabled()); - Assertions.assertTrue(serverInfos.isWebsocketEnabled()); - Assertions.assertEquals(900000, serverInfos.getWebsocketPingInterval()); - Assertions.assertEquals(30000, serverInfos.getTreeReloadInterval()); - Assertions.assertEquals(60000, serverInfos.getForceRefreshCooldownDuration()); - - } -} +package com.commafeed.integration.rest; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import com.commafeed.frontend.model.ServerInfo; +import com.commafeed.integration.BaseIT; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; + +@QuarkusTest +class ServerIT extends BaseIT { + + @Test + void getServerInfos() { + ServerInfo serverInfos = RestAssured.given().get("/rest/server/get").then().statusCode(200).extract().as(ServerInfo.class); + Assertions.assertTrue(serverInfos.isAllowRegistrations()); + Assertions.assertTrue(serverInfos.isSmtpEnabled()); + Assertions.assertTrue(serverInfos.isDemoAccountEnabled()); + Assertions.assertTrue(serverInfos.isWebsocketEnabled()); + Assertions.assertEquals(900000, serverInfos.getWebsocketPingInterval()); + Assertions.assertEquals(30000, serverInfos.getTreeReloadInterval()); + Assertions.assertEquals(60000, serverInfos.getForceRefreshCooldownDuration()); + + } +} diff --git a/commafeed-server/src/test/java/com/commafeed/integration/rest/UserIT.java b/commafeed-server/src/test/java/com/commafeed/integration/rest/UserIT.java index cf6a3e4e..f3d12b2b 100644 --- a/commafeed-server/src/test/java/com/commafeed/integration/rest/UserIT.java +++ b/commafeed-server/src/test/java/com/commafeed/integration/rest/UserIT.java @@ -1,53 +1,53 @@ -package com.commafeed.integration.rest; - -import java.util.List; - -import jakarta.inject.Inject; -import jakarta.ws.rs.core.MediaType; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import com.commafeed.frontend.model.request.PasswordResetRequest; -import com.commafeed.integration.BaseIT; - -import io.quarkus.mailer.MockMailbox; -import io.quarkus.test.junit.QuarkusTest; -import io.restassured.RestAssured; -import io.vertx.ext.mail.MailMessage; - -@QuarkusTest -class UserIT extends BaseIT { - - @Inject - MockMailbox mailbox; - - @BeforeEach - void setup() { - RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); - - mailbox.clear(); - } - - @AfterEach - void cleanup() { - RestAssured.reset(); - } - - @Test - void resetPassword() { - PasswordResetRequest req = new PasswordResetRequest(); - req.setEmail("admin@commafeed.com"); - RestAssured.given().body(req).contentType(MediaType.APPLICATION_JSON).post("rest/user/passwordReset").then().statusCode(200); - - List mails = mailbox.getMailMessagesSentTo("admin@commafeed.com"); - Assertions.assertEquals(1, mails.size()); - - MailMessage message = mails.get(0); - Assertions.assertEquals("CommaFeed - Password recovery", message.getSubject()); - Assertions.assertTrue(message.getHtml().startsWith("You asked for password recovery for account 'admin'")); - Assertions.assertEquals("admin@commafeed.com", message.getTo().get(0)); - } -} +package com.commafeed.integration.rest; + +import java.util.List; + +import jakarta.inject.Inject; +import jakarta.ws.rs.core.MediaType; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.commafeed.frontend.model.request.PasswordResetRequest; +import com.commafeed.integration.BaseIT; + +import io.quarkus.mailer.MockMailbox; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import io.vertx.ext.mail.MailMessage; + +@QuarkusTest +class UserIT extends BaseIT { + + @Inject + MockMailbox mailbox; + + @BeforeEach + void setup() { + RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); + + mailbox.clear(); + } + + @AfterEach + void cleanup() { + RestAssured.reset(); + } + + @Test + void resetPassword() { + PasswordResetRequest req = new PasswordResetRequest(); + req.setEmail("admin@commafeed.com"); + RestAssured.given().body(req).contentType(MediaType.APPLICATION_JSON).post("rest/user/passwordReset").then().statusCode(200); + + List mails = mailbox.getMailMessagesSentTo("admin@commafeed.com"); + Assertions.assertEquals(1, mails.size()); + + MailMessage message = mails.get(0); + Assertions.assertEquals("CommaFeed - Password recovery", message.getSubject()); + Assertions.assertTrue(message.getHtml().startsWith("You asked for password recovery for account 'admin'")); + Assertions.assertEquals("admin@commafeed.com", message.getTo().get(0)); + } +} diff --git a/commafeed-server/src/test/java/com/commafeed/integration/servlet/CustomCodeIT.java b/commafeed-server/src/test/java/com/commafeed/integration/servlet/CustomCodeIT.java index b641d646..4eebe625 100644 --- a/commafeed-server/src/test/java/com/commafeed/integration/servlet/CustomCodeIT.java +++ b/commafeed-server/src/test/java/com/commafeed/integration/servlet/CustomCodeIT.java @@ -1,49 +1,49 @@ -package com.commafeed.integration.servlet; - -import jakarta.ws.rs.core.MediaType; - -import org.apache.hc.core5.http.HttpStatus; -import org.hamcrest.CoreMatchers; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import com.commafeed.frontend.model.Settings; -import com.commafeed.integration.BaseIT; - -import io.quarkus.test.junit.QuarkusTest; -import io.restassured.RestAssured; - -@QuarkusTest -class CustomCodeIT extends BaseIT { - - @BeforeEach - void setup() { - RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); - } - - @AfterEach - void cleanup() { - RestAssured.reset(); - } - - @Test - void test() { - // get settings - Settings settings = RestAssured.given().get("rest/user/settings").then().statusCode(200).extract().as(Settings.class); - - // update settings - settings.setCustomJs("custom-js"); - settings.setCustomCss("custom-css"); - RestAssured.given() - .body(settings) - .contentType(MediaType.APPLICATION_JSON) - .post("rest/user/settings") - .then() - .statusCode(HttpStatus.SC_OK); - - // check custom code servlets - RestAssured.given().get("custom_js.js").then().statusCode(HttpStatus.SC_OK).body(CoreMatchers.is("custom-js")); - RestAssured.given().get("custom_css.css").then().statusCode(HttpStatus.SC_OK).body(CoreMatchers.is("custom-css")); - } -} +package com.commafeed.integration.servlet; + +import jakarta.ws.rs.core.MediaType; + +import org.apache.hc.core5.http.HttpStatus; +import org.hamcrest.CoreMatchers; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.commafeed.frontend.model.Settings; +import com.commafeed.integration.BaseIT; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; + +@QuarkusTest +class CustomCodeIT extends BaseIT { + + @BeforeEach + void setup() { + RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); + } + + @AfterEach + void cleanup() { + RestAssured.reset(); + } + + @Test + void test() { + // get settings + Settings settings = RestAssured.given().get("rest/user/settings").then().statusCode(200).extract().as(Settings.class); + + // update settings + settings.setCustomJs("custom-js"); + settings.setCustomCss("custom-css"); + RestAssured.given() + .body(settings) + .contentType(MediaType.APPLICATION_JSON) + .post("rest/user/settings") + .then() + .statusCode(HttpStatus.SC_OK); + + // check custom code servlets + RestAssured.given().get("custom_js.js").then().statusCode(HttpStatus.SC_OK).body(CoreMatchers.is("custom-js")); + RestAssured.given().get("custom_css.css").then().statusCode(HttpStatus.SC_OK).body(CoreMatchers.is("custom-css")); + } +} diff --git a/commafeed-server/src/test/java/com/commafeed/integration/servlet/LogoutIT.java b/commafeed-server/src/test/java/com/commafeed/integration/servlet/LogoutIT.java index 1e6829ac..d9cebad6 100644 --- a/commafeed-server/src/test/java/com/commafeed/integration/servlet/LogoutIT.java +++ b/commafeed-server/src/test/java/com/commafeed/integration/servlet/LogoutIT.java @@ -1,38 +1,38 @@ -package com.commafeed.integration.servlet; - -import java.net.HttpCookie; -import java.util.List; -import java.util.stream.Collectors; - -import jakarta.ws.rs.core.HttpHeaders; - -import org.apache.hc.core5.http.HttpStatus; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import com.commafeed.integration.BaseIT; - -import io.quarkus.test.junit.QuarkusTest; -import io.restassured.RestAssured; -import io.restassured.http.Headers; - -@QuarkusTest -class LogoutIT extends BaseIT { - - @Test - void test() { - List cookies = login(); - Headers responseHeaders = RestAssured.given() - .header(HttpHeaders.COOKIE, cookies.stream().map(HttpCookie::toString).collect(Collectors.joining(";"))) - .redirects() - .follow(false) - .get("logout") - .then() - .statusCode(HttpStatus.SC_TEMPORARY_REDIRECT) - .extract() - .headers(); - - List setCookieHeaders = responseHeaders.getValues(HttpHeaders.SET_COOKIE); - Assertions.assertTrue(setCookieHeaders.stream().flatMap(c -> HttpCookie.parse(c).stream()).allMatch(c -> c.getMaxAge() == 0)); - } -} +package com.commafeed.integration.servlet; + +import java.net.HttpCookie; +import java.util.List; +import java.util.stream.Collectors; + +import jakarta.ws.rs.core.HttpHeaders; + +import org.apache.hc.core5.http.HttpStatus; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import com.commafeed.integration.BaseIT; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import io.restassured.http.Headers; + +@QuarkusTest +class LogoutIT extends BaseIT { + + @Test + void test() { + List cookies = login(); + Headers responseHeaders = RestAssured.given() + .header(HttpHeaders.COOKIE, cookies.stream().map(HttpCookie::toString).collect(Collectors.joining(";"))) + .redirects() + .follow(false) + .get("logout") + .then() + .statusCode(HttpStatus.SC_TEMPORARY_REDIRECT) + .extract() + .headers(); + + List setCookieHeaders = responseHeaders.getValues(HttpHeaders.SET_COOKIE); + Assertions.assertTrue(setCookieHeaders.stream().flatMap(c -> HttpCookie.parse(c).stream()).allMatch(c -> c.getMaxAge() == 0)); + } +} diff --git a/commafeed-server/src/test/java/com/commafeed/integration/servlet/NextUnreadIT.java b/commafeed-server/src/test/java/com/commafeed/integration/servlet/NextUnreadIT.java index 0d19a71f..338ccbb0 100644 --- a/commafeed-server/src/test/java/com/commafeed/integration/servlet/NextUnreadIT.java +++ b/commafeed-server/src/test/java/com/commafeed/integration/servlet/NextUnreadIT.java @@ -1,41 +1,41 @@ -package com.commafeed.integration.servlet; - -import jakarta.ws.rs.core.HttpHeaders; - -import org.apache.hc.core5.http.HttpStatus; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import com.commafeed.integration.BaseIT; - -import io.quarkus.test.junit.QuarkusTest; -import io.restassured.RestAssured; - -@QuarkusTest -class NextUnreadIT extends BaseIT { - - @BeforeEach - void setup() { - RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); - } - - @AfterEach - void cleanup() { - RestAssured.reset(); - } - - @Test - void test() { - subscribeAndWaitForEntries(getFeedUrl()); - - RestAssured.given() - .redirects() - .follow(false) - .get("next") - .then() - .statusCode(HttpStatus.SC_TEMPORARY_REDIRECT) - .header(HttpHeaders.LOCATION, "https://hostname.local/commafeed/2"); - } - -} +package com.commafeed.integration.servlet; + +import jakarta.ws.rs.core.HttpHeaders; + +import org.apache.hc.core5.http.HttpStatus; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.commafeed.integration.BaseIT; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; + +@QuarkusTest +class NextUnreadIT extends BaseIT { + + @BeforeEach + void setup() { + RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); + } + + @AfterEach + void cleanup() { + RestAssured.reset(); + } + + @Test + void test() { + subscribeAndWaitForEntries(getFeedUrl()); + + RestAssured.given() + .redirects() + .follow(false) + .get("next") + .then() + .statusCode(HttpStatus.SC_TEMPORARY_REDIRECT) + .header(HttpHeaders.LOCATION, "https://hostname.local/commafeed/2"); + } + +} diff --git a/commafeed-server/src/test/java/com/commafeed/integration/servlet/RobotsTxtIT.java b/commafeed-server/src/test/java/com/commafeed/integration/servlet/RobotsTxtIT.java index aa153217..1a99563d 100644 --- a/commafeed-server/src/test/java/com/commafeed/integration/servlet/RobotsTxtIT.java +++ b/commafeed-server/src/test/java/com/commafeed/integration/servlet/RobotsTxtIT.java @@ -1,17 +1,17 @@ -package com.commafeed.integration.servlet; - -import org.hamcrest.CoreMatchers; -import org.junit.jupiter.api.Test; - -import com.commafeed.integration.BaseIT; - -import io.quarkus.test.junit.QuarkusTest; -import io.restassured.RestAssured; - -@QuarkusTest -class RobotsTxtIT extends BaseIT { - @Test - void test() { - RestAssured.given().get("robots.txt").then().statusCode(200).body(CoreMatchers.is("User-agent: *\nDisallow: /")); - } -} +package com.commafeed.integration.servlet; + +import org.hamcrest.CoreMatchers; +import org.junit.jupiter.api.Test; + +import com.commafeed.integration.BaseIT; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; + +@QuarkusTest +class RobotsTxtIT extends BaseIT { + @Test + void test() { + RestAssured.given().get("robots.txt").then().statusCode(200).body(CoreMatchers.is("User-agent: *\nDisallow: /")); + } +} diff --git a/commafeed-server/src/test/resources/feed/rss.xml b/commafeed-server/src/test/resources/feed/rss.xml index 220e9128..68c38630 100644 --- a/commafeed-server/src/test/resources/feed/rss.xml +++ b/commafeed-server/src/test/resources/feed/rss.xml @@ -1,20 +1,20 @@ - - - - CommaFeed test feed - https://hostname.local/commafeed - CommaFeed test feed description - - Item 2 - https://hostname.local/commafeed/2 - Item 2 description - Fri, 29 Dec 2023 15:02:00 +0100 - - - Item 1 - https://hostname.local/commafeed/1 - Item 1 description - Wed, 27 Dec 2023 22:24:00 +0100 - - + + + + CommaFeed test feed + https://hostname.local/commafeed + CommaFeed test feed description + + Item 2 + https://hostname.local/commafeed/2 + Item 2 description + Fri, 29 Dec 2023 15:02:00 +0100 + + + Item 1 + https://hostname.local/commafeed/1 + Item 1 description + Wed, 27 Dec 2023 22:24:00 +0100 + + \ No newline at end of file diff --git a/commafeed-server/src/test/resources/feed/rss_2.xml b/commafeed-server/src/test/resources/feed/rss_2.xml index 8b169494..b209a578 100644 --- a/commafeed-server/src/test/resources/feed/rss_2.xml +++ b/commafeed-server/src/test/resources/feed/rss_2.xml @@ -1,32 +1,32 @@ - - - - CommaFeed test feed - https://hostname.local/commafeed - CommaFeed test feed description - - Item 4 - https://hostname.local/commafeed/4 - Item 4 description - Sun, 31 Dec 2023 15:00:00 +0100 - - - Item 3 - https://hostname.local/commafeed/3 - Item 3 description - Sat, 30 Dec 2023 15:00:00 +0100 - - - Item 2 - https://hostname.local/commafeed/2 - Item 2 description - Fri, 29 Dec 2023 15:02:00 +0100 - - - Item 1 - https://hostname.local/commafeed/1 - Item 1 description - Wed, 27 Dec 2023 22:24:00 +0100 - - + + + + CommaFeed test feed + https://hostname.local/commafeed + CommaFeed test feed description + + Item 4 + https://hostname.local/commafeed/4 + Item 4 description + Sun, 31 Dec 2023 15:00:00 +0100 + + + Item 3 + https://hostname.local/commafeed/3 + Item 3 description + Sat, 30 Dec 2023 15:00:00 +0100 + + + Item 2 + https://hostname.local/commafeed/2 + Item 2 description + Fri, 29 Dec 2023 15:02:00 +0100 + + + Item 1 + https://hostname.local/commafeed/1 + Item 1 description + Wed, 27 Dec 2023 22:24:00 +0100 + + \ No newline at end of file diff --git a/commafeed-server/src/test/resources/opml/opml_noversion.xml b/commafeed-server/src/test/resources/opml/opml_noversion.xml index e0d47d44..d1a53f65 100644 --- a/commafeed-server/src/test/resources/opml/opml_noversion.xml +++ b/commafeed-server/src/test/resources/opml/opml_noversion.xml @@ -1,12 +1,12 @@ - - - - subscriptions - - - - - - - + + + + subscriptions + + + + + + + diff --git a/commafeed-server/src/test/resources/opml/opml_v1.0.xml b/commafeed-server/src/test/resources/opml/opml_v1.0.xml index b4754439..09bcdd65 100644 --- a/commafeed-server/src/test/resources/opml/opml_v1.0.xml +++ b/commafeed-server/src/test/resources/opml/opml_v1.0.xml @@ -1,12 +1,12 @@ - - - - subscriptions - - - - - - - + + + + subscriptions + + + + + + + diff --git a/commafeed-server/src/test/resources/opml/opml_v1.1.xml b/commafeed-server/src/test/resources/opml/opml_v1.1.xml index f3e94135..c5e1b00c 100644 --- a/commafeed-server/src/test/resources/opml/opml_v1.1.xml +++ b/commafeed-server/src/test/resources/opml/opml_v1.1.xml @@ -1,12 +1,12 @@ - - - - subscriptions - - - - - - - + + + + subscriptions + + + + + + + diff --git a/commafeed-server/src/test/resources/opml/opml_v2.0.xml b/commafeed-server/src/test/resources/opml/opml_v2.0.xml index c8f9a7f9..46618e32 100644 --- a/commafeed-server/src/test/resources/opml/opml_v2.0.xml +++ b/commafeed-server/src/test/resources/opml/opml_v2.0.xml @@ -1,12 +1,12 @@ - - - - subscriptions - - - - - - - + + + + subscriptions + + + + + + + diff --git a/mvnw.cmd b/mvnw.cmd index b150b91e..249bdf38 100644 --- a/mvnw.cmd +++ b/mvnw.cmd @@ -1,149 +1,149 @@ -<# : batch portion -@REM ---------------------------------------------------------------------------- -@REM Licensed to the Apache Software Foundation (ASF) under one -@REM or more contributor license agreements. See the NOTICE file -@REM distributed with this work for additional information -@REM regarding copyright ownership. The ASF licenses this file -@REM to you under the Apache License, Version 2.0 (the -@REM "License"); you may not use this file except in compliance -@REM with the License. You may obtain a copy of the License at -@REM -@REM http://www.apache.org/licenses/LICENSE-2.0 -@REM -@REM Unless required by applicable law or agreed to in writing, -@REM software distributed under the License is distributed on an -@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -@REM KIND, either express or implied. See the License for the -@REM specific language governing permissions and limitations -@REM under the License. -@REM ---------------------------------------------------------------------------- - -@REM ---------------------------------------------------------------------------- -@REM Apache Maven Wrapper startup batch script, version 3.3.2 -@REM -@REM Optional ENV vars -@REM MVNW_REPOURL - repo url base for downloading maven distribution -@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven -@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output -@REM ---------------------------------------------------------------------------- - -@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) -@SET __MVNW_CMD__= -@SET __MVNW_ERROR__= -@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% -@SET PSModulePath= -@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( - IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) -) -@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% -@SET __MVNW_PSMODULEP_SAVE= -@SET __MVNW_ARG0_NAME__= -@SET MVNW_USERNAME= -@SET MVNW_PASSWORD= -@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) -@echo Cannot start maven from wrapper >&2 && exit /b 1 -@GOTO :EOF -: end batch / begin powershell #> - -$ErrorActionPreference = "Stop" -if ($env:MVNW_VERBOSE -eq "true") { - $VerbosePreference = "Continue" -} - -# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties -$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl -if (!$distributionUrl) { - Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" -} - -switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { - "maven-mvnd-*" { - $USE_MVND = $true - $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" - $MVN_CMD = "mvnd.cmd" - break - } - default { - $USE_MVND = $false - $MVN_CMD = $script -replace '^mvnw','mvn' - break - } -} - -# apply MVNW_REPOURL and calculate MAVEN_HOME -# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ -if ($env:MVNW_REPOURL) { - $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } - $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" -} -$distributionUrlName = $distributionUrl -replace '^.*/','' -$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' -$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" -if ($env:MAVEN_USER_HOME) { - $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" -} -$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' -$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" - -if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { - Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" - Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" - exit $? -} - -if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { - Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" -} - -# prepare tmp dir -$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile -$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" -$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null -trap { - if ($TMP_DOWNLOAD_DIR.Exists) { - try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } - catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } - } -} - -New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null - -# Download and Install Apache Maven -Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." -Write-Verbose "Downloading from: $distributionUrl" -Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" - -$webclient = New-Object System.Net.WebClient -if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { - $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) -} -[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null - -# If specified, validate the SHA-256 sum of the Maven distribution zip file -$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum -if ($distributionSha256Sum) { - if ($USE_MVND) { - Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." - } - Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash - if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { - Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." - } -} - -# unzip and move -Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null -Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null -try { - Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null -} catch { - if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { - Write-Error "fail to move MAVEN_HOME" - } -} finally { - try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } - catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } -} - -Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.2 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' +$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml index 65de13c4..9908bc5b 100644 --- a/pom.xml +++ b/pom.xml @@ -1,49 +1,49 @@ - - - 4.0.0 - - com.commafeed - commafeed - 5.6.1 - CommaFeed - pom - - - UTF-8 - 17 - - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.14.0 - - true - - - - true - - - -proc:full - - - - - -Xlint:all,-processing,-classfile - -Werror - - - - - - - - commafeed-client - commafeed-server - - + + + 4.0.0 + + com.commafeed + commafeed + 5.6.1 + CommaFeed + pom + + + UTF-8 + 17 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + true + + + + true + + + -proc:full + + + + + -Xlint:all,-processing,-classfile + -Werror + + + + + + + + commafeed-client + commafeed-server + + \ No newline at end of file