diff --git a/.dockerignore b/.dockerignore index 7b78e880..cbcd6b3e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1 @@ -# ignore everything -* - -# allow only what we need -!commafeed-server/target/commafeed.jar -!commafeed-server/config.yml.example - +commafeed-client \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 06d3ded3..00000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,121 +0,0 @@ -name: Java CI - -on: [ push ] - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - java: [ "17", "21" ] - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # Setup - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Set up Java - uses: actions/setup-java@v4 - with: - java-version: ${{ matrix.java }} - distribution: "temurin" - cache: "maven" - - # Build & Test - - name: Build with Maven - run: mvn --batch-mode --no-transfer-progress install - env: - TEST_DATABASE: h2 - - - name: Run integration tests on PostgreSQL - run: mvn --batch-mode --no-transfer-progress failsafe:integration-test failsafe:verify - env: - TEST_DATABASE: postgresql - - - name: Run integration tests on MySQL - run: mvn --batch-mode --no-transfer-progress failsafe:integration-test failsafe:verify - env: - TEST_DATABASE: mysql - - - name: Run integration tests on MariaDB - run: mvn --batch-mode --no-transfer-progress failsafe:integration-test failsafe:verify - env: - TEST_DATABASE: mariadb - - - name: Run integration tests with Redis cache enabled - run: mvn --batch-mode --no-transfer-progress failsafe:integration-test failsafe:verify - env: - TEST_DATABASE: h2 - REDIS: true - - # Upload artifacts - - name: Upload JAR - uses: actions/upload-artifact@v4 - if: ${{ matrix.java == '17' }} - with: - name: commafeed.jar - path: commafeed-server/target/commafeed.jar - - - name: Upload Playwright artifacts - if: failure() - uses: actions/upload-artifact@v4 - with: - name: playwright-artifacts - path: | - **/target/playwright-artifacts/ - - # Docker - - name: Login to Container Registry - uses: docker/login-action@v3 - if: ${{ matrix.java == '17' && (github.ref_type == 'tag' || github.ref_name == 'master') }} - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Docker build and push tag - uses: docker/build-push-action@v6 - if: ${{ matrix.java == '17' && github.ref_type == 'tag' }} - with: - context: . - push: true - platforms: linux/amd64,linux/arm64/v8 - tags: | - athou/commafeed:latest - athou/commafeed:${{ github.ref_name }} - - - name: Docker build and push master - uses: docker/build-push-action@v6 - if: ${{ matrix.java == '17' && github.ref_name == 'master' }} - with: - context: . - push: true - platforms: linux/amd64,linux/arm64/v8 - tags: athou/commafeed:master - - # Create GitHub release after Docker image has been published - - name: Extract Changelog Entry - uses: mindsers/changelog-reader-action@v2 - if: ${{ matrix.java == '17' && github.ref_type == 'tag' }} - id: changelog_reader - with: - version: ${{ github.ref_name }} - - - name: Create GitHub release - uses: softprops/action-gh-release@v2 - if: ${{ matrix.java == '17' && github.ref_type == 'tag' }} - with: - name: CommaFeed ${{ github.ref_name }} - body: ${{ steps.changelog_reader.outputs.changes }} - draft: false - prerelease: false - files: | - commafeed-server/target/commafeed.jar - commafeed-server/config.yml.example diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..bc00e4d6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,197 @@ +name: ci + +on: [ push ] + +env: + JAVA_VERSION: 21 + DOCKER_BUILD_SUMMARY: false + +jobs: + build-linux: + runs-on: ubuntu-latest + strategy: + matrix: + database: [ "h2", "postgresql", "mysql", "mariadb" ] + + steps: + # Checkout + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Setup + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Set up GraalVM + uses: graalvm/setup-graalvm@v1 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: "graalvm" + cache: "maven" + + # Build & Test + - name: Build with Maven + run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} + + # Upload artifacts + - name: Upload cross-platform app + uses: actions/upload-artifact@v4 + with: + name: commafeed-${{ matrix.database }}-jvm + path: commafeed-server/target/commafeed-*.zip + + - name: Upload native executable + uses: actions/upload-artifact@v4 + with: + name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }} + path: commafeed-server/target/commafeed-*-runner + + # Docker + - name: Login to Container Registry + uses: docker/login-action@v3 + if: ${{ github.ref_type == 'tag' || github.ref_name == 'master' || github.ref_name == 'quarkus' }} + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + ## tags + - name: Docker build and push tag - native + uses: docker/build-push-action@v6 + if: ${{ github.ref_type == 'tag' }} + with: + context: . + file: commafeed-server/src/main/docker/Dockerfile.native + push: true + platforms: linux/amd64 + tags: | + athou/commafeed:latest-${{ matrix.database }} + athou/commafeed:${{ github.ref_name }}-${{ matrix.database }} + + - name: Docker build and push tag - jvm + uses: docker/build-push-action@v6 + if: ${{ github.ref_type == 'tag' }} + with: + context: . + file: commafeed-server/src/main/docker/Dockerfile.jvm + push: true + platforms: linux/amd64,linux/arm64/v8 + tags: | + athou/commafeed:latest-${{ matrix.database }}-jvm + athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}-jvm + + ## master + - name: Docker build and push master - native + uses: docker/build-push-action@v6 + if: ${{ github.ref_name == 'master' }} + with: + context: . + file: commafeed-server/src/main/docker/Dockerfile.native + push: true + platforms: linux/amd64 + tags: athou/commafeed:master-${{ matrix.database }} + + - name: Docker build and push master - jvm + uses: docker/build-push-action@v6 + if: ${{ github.ref_name == 'master' }} + with: + context: . + file: commafeed-server/src/main/docker/Dockerfile.jvm + push: true + platforms: linux/amd64,linux/arm64/v8 + tags: athou/commafeed:master-${{ matrix.database }}-jvm + + ## quarkus branch - remove when merged into master, also remove the condition in the login step + - name: Docker build and push quarkus - native + uses: docker/build-push-action@v6 + if: ${{ github.ref_name == 'quarkus' }} + with: + context: . + file: commafeed-server/src/main/docker/Dockerfile.native + push: true + platforms: linux/amd64 + tags: athou/commafeed:quarkus-${{ matrix.database }} + + - name: Docker build and push quarkus - jvm + uses: docker/build-push-action@v6 + if: ${{ github.ref_name == 'quarkus' }} + with: + context: . + file: commafeed-server/src/main/docker/Dockerfile.jvm + push: true + platforms: linux/amd64,linux/arm64/v8 + tags: athou/commafeed:quarkus-${{ matrix.database }}-jvm + + build-windows: + runs-on: windows-latest + strategy: + matrix: + database: [ "h2", "postgresql", "mysql", "mariadb" ] + + steps: + # Checkout + - name: Configure git to checkout as-is + run: git config --global core.autocrlf false + + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Setup + - name: Set up GraalVM + uses: graalvm/setup-graalvm@v1 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: "graalvm" + cache: "maven" + + # Build & Test + - name: Build with Maven + run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipTests=${{ matrix.database != 'h2' }} + + # Upload artifacts + - name: Upload native executable + uses: actions/upload-artifact@v4 + with: + name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }} + path: commafeed-server/target/commafeed-*-runner.exe + + release: + runs-on: ubuntu-latest + needs: + - build-linux + - build-windows + if: github.ref_type == 'tag' + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + pattern: commafeed-* + path: ./artifacts + merge-multiple: true + + - name: Extract Changelog Entry + uses: mindsers/changelog-reader-action@v2 + id: changelog_reader + with: + version: ${{ github.ref_name }} + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + name: CommaFeed ${{ github.ref_name }} + body: ${{ steps.changelog_reader.outputs.changes }} + draft: false + prerelease: false + files: ./artifacts/* diff --git a/.github/workflows/dockerhub.yml b/.github/workflows/dockerhub.yml new file mode 100644 index 00000000..8392b5fb --- /dev/null +++ b/.github/workflows/dockerhub.yml @@ -0,0 +1,18 @@ +name: Update Docker Hub Description + +on: release + +jobs: + dockerHubDescription: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Docker Hub Description + uses: peter-evans/dockerhub-description@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 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index ebdeb4bf..0eb1c3c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## [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% diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 4535100e..00000000 --- a/Dockerfile +++ /dev/null @@ -1,19 +0,0 @@ -FROM ibm-semeru-runtimes:open-21.0.4_7-jre - -EXPOSE 8082 - -RUN mkdir -p /commafeed/data -VOLUME /commafeed/data - -COPY commafeed-server/config.yml.example config.yml -COPY commafeed-server/target/commafeed.jar . - -CMD ["java", \ - "-Djava.net.preferIPv4Stack=true", \ - "-Xtune:virtualized", \ - "-Xminf0.05", \ - "-Xmaxf0.1", \ - "-jar", \ - "commafeed.jar", \ - "server", \ - "config.yml"] diff --git a/README.md b/README.md index 3c8c0942..56c49a24 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CommaFeed -Google Reader inspired self-hosted RSS reader, based on Dropwizard and React/TypeScript. +Google Reader inspired self-hosted RSS reader, based on Quarkus and React/TypeScript. ![preview](https://user-images.githubusercontent.com/1256795/184886828-1973f148-58a9-4c6d-9587-ee5e5d3cc2cb.png) @@ -8,14 +8,22 @@ Google Reader inspired self-hosted RSS reader, based on Dropwizard and React/Typ - 4 different layouts - Light/Dark theme -- Fully responsive +- Fully responsive, works great on both mobile and desktop - Keyboard shortcuts for almost everything - Support for right-to-left feeds - Translated in 25+ languages - Supports thousands of users and millions of feeds - OPML import/export -- REST API and a Fever-compatible API for native mobile apps +- REST API +- Fever-compatible API for native mobile apps +- Can automatically mark articles as read based on user-defined rules - [Browser extension](https://github.com/Athou/commafeed-browser-extension) +- Compiles to native code for blazing fast startup and low memory usage +- Supports 4 databases + - H2 (embedded database) + - PostgreSQL + - MySQL + - MariaDB ## Deployment @@ -33,28 +41,75 @@ PikaPods shares 20% of the revenue back to CommaFeed. [![PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=commafeed) -### Download precompiled package +### Download a precompiled package - mkdir commafeed && cd commafeed - wget https://github.com/Athou/commafeed/releases/latest/download/commafeed.jar - wget https://github.com/Athou/commafeed/releases/latest/download/config.yml.example -O config.yml - java -Djava.net.preferIPv4Stack=true -jar commafeed.jar server config.yml +Go to the [release page](https://github.com/Athou/commafeed/releases) and download the latest version for your operating +system and database of choice. -The server will listen on http://localhost:8082. The default -user is `admin` and the default password is `admin`. +There are two types of packages: + +- The `linux-x86_64` and `windows-x86_64` packages are compiled natively and contain an executable that can be run + directly. +- The `jvm` package is a zip file containing all `.jar` files required to run the application. This package works on all + platforms and is started with `java -jar quarkus-run.jar`. + +If available for your operating system, the native package is recommended because it has a faster startup time and lower +memory usage. ### Build from sources - git clone https://github.com/Athou/commafeed.git - cd commafeed - ./mvnw clean package - cp commafeed-server/config.yml.example config.yml - java -Djava.net.preferIPv4Stack=true -jar commafeed-server/target/commafeed.jar server config.yml + ./mvnw clean package [-P] [-Pnative] [-DskipTests] -The server will listen on http://localhost:8082. The default -user is `admin` and the default password is `admin`. +- `` can be one of `h2`, `postgresql`, `mysql` or `mariadb`. The default is `h2`. +- `-Pnative` compiles the application to native code. This requires GraalVM to be installed (`GRAALVM_HOME` environment + variable pointing to a GraalVM installation). +- `-DskipTests` to speed up the build process by skipping tests. -### Memory management +When the build is complete: + +- a zip containing all jars required to run the application is located at + `commafeed-server/target/commafeed---jvm.zip`. Extract it and run the application with + `java -jar quarkus-run.jar` +- if you used the native profile, the executable is located at + `commafeed-server/target/commafeed-----runner[.exe]` + +## Configuration + +CommaFeed doesn't require any configuration to run with its embedded database (H2). The database file will be stored in +the `data` directory of the current directory. + +To use a different database, you will need to configure the following properties: + +- `quarkus.datasource.jdbc-url` + - e.g. for H2: `jdbc:h2:./data/db;DEFRAG_ALWAYS=TRUE` + - e.g. for PostgreSQL: `jdbc:postgresql://localhost:5432/commafeed` + - e.g. for MySQL: + `jdbc:mysql://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC` + - e.g. for MariaDB: + `jdbc:mariadb://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC` +- `quarkus.datasource.username` +- `quarkus.datasource.password` + +There are multiple ways to configure CommaFeed: + +- a [properties](https://en.wikipedia.org/wiki/.properties) file in `config/application.properties` (keys in kebab-case) +- Command line arguments prefixed with `-D` (keys in kebab-case) +- Environment variables (keys in UPPER_CASE) +- an .env file in the working directory (keys in UPPER_CASE) + +The properties file is recommended because CommaFeed will be able to warn about invalid properties and typos. + +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` property to a fixed value (min. 16 characters). + +All [CommaFeed settings](commafeed-server/src/main/java/com/commafeed/CommaFeedConfiguration.java) +are optional and have sensible default values. + +When started, the server will listen on http://localhost:8082. +The default user is `admin` and the default password is `admin`. + +### Memory management (`jvm` package only) The Java Virtual Machine (JVM) is rather greedy by default and will not release unused memory to the operating system. This is because acquiring memory from the operating system is a relatively expensive operation. @@ -108,7 +163,7 @@ two-letters [ISO-639-1 language code](http://en.wikipedia.org/wiki/List_of_ISO_6 - Open `commafeed-server` in your preferred Java IDE. - CommaFeed uses Lombok, you need the Lombok plugin for your IDE. -- Start `CommaFeedApplication.java` in debug mode with `server config.dev.yml` as arguments +- run `./mvnw quarkus:dev` ### Frontend diff --git a/commafeed-client/pom.xml b/commafeed-client/pom.xml index 7049bbff..753ee1ec 100644 --- a/commafeed-client/pom.xml +++ b/commafeed-client/pom.xml @@ -6,7 +6,7 @@ com.commafeed commafeed - 4.6.0 + 5.0.0-beta commafeed-client CommaFeed Client @@ -80,7 +80,7 @@ copy-resources - ${project.build.directory}/classes/assets + ${project.build.directory}/classes/META-INF/resources dist diff --git a/commafeed-client/src/app/client.ts b/commafeed-client/src/app/client.ts index 3e9a7cd1..e34759ed 100644 --- a/commafeed-client/src/app/client.ts +++ b/commafeed-client/src/app/client.ts @@ -81,7 +81,17 @@ export const client = { }, }, user: { - login: async (req: LoginRequest) => await axiosInstance.post("user/login", req), + login: async (req: LoginRequest) => { + const formData = new URLSearchParams() + formData.append("j_username", req.name) + formData.append("j_password", req.password) + return await axiosInstance.post("j_security_check", formData, { + baseURL: ".", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }) + }, register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req), passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req), getSettings: async () => await axiosInstance.get("user/settings"), diff --git a/commafeed-client/src/pages/admin/MetricsPage.tsx b/commafeed-client/src/pages/admin/MetricsPage.tsx index b0de53e1..d90e6227 100644 --- a/commafeed-client/src/pages/admin/MetricsPage.tsx +++ b/commafeed-client/src/pages/admin/MetricsPage.tsx @@ -1,20 +1,17 @@ -import { Accordion, Box, Tabs } from "@mantine/core" +import { Accordion, Box } from "@mantine/core" import { client } from "app/client" import { Loader } from "components/Loader" import { Gauge } from "components/metrics/Gauge" import { Meter } from "components/metrics/Meter" import { MetricAccordionItem } from "components/metrics/MetricAccordionItem" -import { Timer } from "components/metrics/Timer" import { useEffect } from "react" import { useAsync } from "react-async-hook" -import { TbChartAreaLine, TbClock } from "react-icons/tb" const shownMeters: Record = { "com.commafeed.backend.feed.FeedRefreshEngine.refill": "Feed queue refill rate", "com.commafeed.backend.feed.FeedRefreshWorker.feedFetched": "Feed fetching rate", "com.commafeed.backend.feed.FeedRefreshUpdater.feedUpdated": "Feed update rate", - "com.commafeed.backend.feed.FeedRefreshUpdater.entryCacheHit": "Entry cache hit rate", - "com.commafeed.backend.feed.FeedRefreshUpdater.entryCacheMiss": "Entry cache miss rate", + "com.commafeed.backend.feed.FeedRefreshUpdater.entryInserted": "Entries inserted", "com.commafeed.backend.service.db.DatabaseCleaningService.entriesDeleted": "Entries deleted", } @@ -42,46 +39,25 @@ export function MetricsPage() { }, [query.execute]) if (!query.result) return - const { meters, gauges, timers } = query.result.data + const { meters, gauges } = query.result.data return ( - - - }> - Stats - - }> - Timers - - + <> + + {Object.keys(shownMeters).map(m => ( + + + + ))} + - - - {Object.keys(shownMeters).map(m => ( - - - - ))} - - - - {Object.keys(shownGauges).map(g => ( - - {shownGauges[g]}:  - - - ))} - - - - - - {Object.keys(timers).map(key => ( - - - - ))} - - - + + {Object.keys(shownGauges).map(g => ( + + {shownGauges[g]}:  + + + ))} + + ) } diff --git a/commafeed-client/src/pages/auth/RegistrationPage.tsx b/commafeed-client/src/pages/auth/RegistrationPage.tsx index a5570687..23dd8fc0 100644 --- a/commafeed-client/src/pages/auth/RegistrationPage.tsx +++ b/commafeed-client/src/pages/auth/RegistrationPage.tsx @@ -24,12 +24,18 @@ export function RegistrationPage() { }, }) - const register = useAsyncCallback(client.user.register, { + const login = useAsyncCallback(client.user.login, { onSuccess: () => { dispatch(redirectToRootCategory()) }, }) + const register = useAsyncCallback(client.user.register, { + onSuccess: () => { + login.execute(form.values) + }, + }) + return ( @@ -50,6 +56,12 @@ export function RegistrationPage() { )} + {login.error && ( + + + + )} +
@@ -68,7 +80,7 @@ export function RegistrationPage() { size="md" required /> -
diff --git a/commafeed-client/vite.config.ts b/commafeed-client/vite.config.ts index eaba0d76..4abeea41 100644 --- a/commafeed-client/vite.config.ts +++ b/commafeed-client/vite.config.ts @@ -32,6 +32,7 @@ export default defineConfig(env => ({ "/openapi.json": "http://localhost:8083", "/custom_css.css": "http://localhost:8083", "/custom_js.js": "http://localhost:8083", + "/j_security_check": "http://localhost:8083", "/logout": "http://localhost:8083", }, }, diff --git a/commafeed-server/config.dev.yml b/commafeed-server/config.dev.yml deleted file mode 100644 index 600490ea..00000000 --- a/commafeed-server/config.dev.yml +++ /dev/null @@ -1,157 +0,0 @@ -# CommaFeed settings -# ------------------ -app: - # url used to access commafeed - publicUrl: http://localhost:8082/ - - # whether to expose a robots.txt file that disallows web crawlers and search engine indexers - hideFromWebCrawlers: true - - # whether to allow user registrations - allowRegistrations: true - - # whether to enable strict password validation (1 uppercase char, 1 lowercase char, 1 digit, 1 special char) - strictPasswordPolicy: true - - # create a demo account the first time the app starts - createDemoAccount: true - - # put your google analytics tracking code here - googleAnalyticsTrackingCode: - - # put your google server key (used for youtube favicon fetching) - googleAuthKey: - - # number of http threads - backgroundThreads: 3 - - # number of database updating threads - databaseUpdateThreads: 1 - - # rows to delete per query while cleaning up old entries - databaseCleanupBatchSize: 100 - - # settings for sending emails (password recovery) - smtpHost: localhost - smtpPort: 25 - smtpTls: false - smtpUserName: user - smtpPassword: pass - smtpFromAddress: - - # Graphite Metric settings - # Allows those who use Graphite to have CommaFeed send metrics for graphing (time in seconds) - graphiteEnabled: false - graphitePrefix: "test.commafeed" - graphiteHost: "localhost" - graphitePort: 2003 - graphiteInterval: 60 - - # whether this commafeed instance has a lot of feeds to refresh - # leave this to false in almost all cases - heavyLoad: false - - # minimum amount of time commafeed will wait before refreshing the same feed - refreshIntervalMinutes: 5 - - # if enabled, images in feed entries will be proxied through the server instead of accessed directly by the browser - # useful if commafeed is usually accessed through a restricting proxy - imageProxyEnabled: true - - # database query timeout (in milliseconds), 0 to disable - queryTimeout: 0 - - # time to keep unread statuses (in days), 0 to disable - keepStatusDays: 0 - - # entries to keep per feed, old entries will be deleted, 0 to disable - maxFeedCapacity: 500 - - # entries older than this will be deleted, 0 to disable - maxEntriesAgeDays: 365 - - # limit the number of feeds a user can subscribe to, 0 to disable - maxFeedsPerUser: 0 - - # don't parse feeds that are too large to prevent memory issues - maxFeedResponseSize: 5M - - # cache service to use, possible values are 'noop' and 'redis' - cache: noop - - # announcement string displayed on the main page - announcement: - - # user-agent string that will be used by the http client, leave empty for the default one - userAgent: - - # enable websocket connection so the server can notify the web client that there are new entries for your feeds - websocketEnabled: true - - # interval at which the client will send a ping message on the websocket to keep the connection alive - websocketPingInterval: 15m - - # if websocket is disabled or the connection is lost, the client will reload the feed tree at this interval - treeReloadInterval: 30s - -# Database connection -# ------------------- -# for MariaDB -# driverClass is org.mariadb.jdbc.Driver -# url is jdbc:mariadb://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC -# -# for MySQL -# driverClass is com.mysql.cj.jdbc.Driver -# url is jdbc:mysql://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC -# -# for PostgreSQL -# driverClass is org.postgresql.Driver -# url is jdbc:postgresql://localhost:5432/commafeed - -database: - driverClass: org.h2.Driver - url: jdbc:h2:./target/commafeed - user: sa - password: sa - properties: - charSet: UTF-8 - validationQuery: "/* CommaFeed Health Check */ SELECT 1" - -server: - applicationConnectors: - - type: http - port: 8083 - adminConnectors: - - type: http - port: 8084 - -logging: - level: INFO - loggers: - com.commafeed: DEBUG - liquibase: INFO - org.hibernate.SQL: INFO # or ALL for sql debugging - org.hibernate.engine.internal.StatisticalLoggingSessionEventListener: WARN - appenders: - - type: console - - type: file - currentLogFilename: log/commafeed.log - threshold: ALL - archive: true - archivedLogFilenamePattern: log/commafeed-%d.log - archivedFileCount: 5 - timeZone: UTC - -# Redis pool configuration -# (only used if app.cache is 'redis') -# ----------------------------------- -redis: - host: localhost - port: 6379 - # username is only required when using ACLs - username: - password: - timeout: 2000 - database: 0 - maxTotal: 500 - \ No newline at end of file diff --git a/commafeed-server/config.yml.example b/commafeed-server/config.yml.example deleted file mode 100644 index 8568b50d..00000000 --- a/commafeed-server/config.yml.example +++ /dev/null @@ -1,158 +0,0 @@ -# CommaFeed settings -# ------------------ -app: - # url used to access commafeed - publicUrl: http://localhost:8082/ - - # whether to expose a robots.txt file that disallows web crawlers and search engine indexers - hideFromWebCrawlers: true - - # whether to allow user registrations - allowRegistrations: false - - # whether to enable strict password validation (1 uppercase char, 1 lowercase char, 1 digit, 1 special char) - strictPasswordPolicy: true - - # create a demo account the first time the app starts - createDemoAccount: false - - # put your google analytics tracking code here - googleAnalyticsTrackingCode: - - # put your google server key (used for youtube favicon fetching) - googleAuthKey: - - # number of http threads - backgroundThreads: 3 - - # number of database updating threads - databaseUpdateThreads: 1 - - # rows to delete per query while cleaning up old entries - databaseCleanupBatchSize: 100 - - # settings for sending emails (password recovery) - smtpHost: - smtpPort: - smtpTls: false - smtpUserName: - smtpPassword: - smtpFromAddress: - - # Graphite Metric settings - # Allows those who use Graphite to have CommaFeed send metrics for graphing (time in seconds) - graphiteEnabled: false - graphitePrefix: "test.commafeed" - graphiteHost: "localhost" - graphitePort: 2003 - graphiteInterval: 60 - - # whether this commafeed instance has a lot of feeds to refresh - # leave this to false in almost all cases - heavyLoad: false - - # minimum amount of time commafeed will wait before refreshing the same feed - refreshIntervalMinutes: 5 - - # if enabled, images in feed entries will be proxied through the server instead of accessed directly by the browser - # useful if commafeed is usually accessed through a restricting proxy - imageProxyEnabled: false - - # database query timeout (in milliseconds), 0 to disable - queryTimeout: 0 - - # time to keep unread statuses (in days), 0 to disable - keepStatusDays: 0 - - # entries to keep per feed, old entries will be deleted, 0 to disable - maxFeedCapacity: 500 - - # entries older than this will be deleted, 0 to disable - maxEntriesAgeDays: 365 - - # limit the number of feeds a user can subscribe to, 0 to disable - maxFeedsPerUser: 0 - - # don't parse feeds that are too large to prevent memory issues - maxFeedResponseSize: 5M - - # cache service to use, possible values are 'noop' and 'redis' - cache: noop - - # announcement string displayed on the main page - announcement: - - # user-agent string that will be used by the http client, leave empty for the default one - userAgent: - - # enable websocket connection so the server can notify the web client that there are new entries for your feeds - websocketEnabled: true - - # interval at which the client will send a ping message on the websocket to keep the connection alive - websocketPingInterval: 15m - - # if websocket is disabled or the connection is lost, the client will reload the feed tree at this interval - treeReloadInterval: 30s - -# Database connection -# ------------------- -# for MariaDB -# driverClass is org.mariadb.jdbc.Driver -# url is jdbc:mariadb://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC -# -# for MySQL -# driverClass is com.mysql.cj.jdbc.Driver -# url is jdbc:mysql://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC -# -# for PostgreSQL -# driverClass is org.postgresql.Driver -# url is jdbc:postgresql://localhost:5432/commafeed - -database: - driverClass: org.h2.Driver - url: jdbc:h2:/commafeed/data/db;DEFRAG_ALWAYS=TRUE - user: sa - password: sa - properties: - charSet: UTF-8 - validationQuery: "/* CommaFeed Health Check */ SELECT 1" - minSize: 1 - maxSize: 50 - maxConnectionAge: 30m - -server: - applicationConnectors: - - type: http - port: 8082 - adminConnectors: [ ] - requestLog: - appenders: [ ] - -logging: - level: ERROR - loggers: - com.commafeed: INFO - liquibase: INFO - io.dropwizard.server.ServerFactory: INFO - appenders: - - type: console - - type: file - currentLogFilename: log/commafeed.log - threshold: ALL - archive: true - archivedLogFilenamePattern: log/commafeed-%d.log - archivedFileCount: 5 - timeZone: UTC - -# Redis pool configuration -# (only used if app.cache is 'redis') -# ----------------------------------- -redis: - host: localhost - port: 6379 - # username is only required when using ACLs - username: - password: - timeout: 2000 - database: 0 - maxTotal: 500 diff --git a/commafeed-server/docker-compose.dev.yml b/commafeed-server/docker-compose.dev.yml index 9fdfb1a6..29b9c6f8 100644 --- a/commafeed-server/docker-compose.dev.yml +++ b/commafeed-server/docker-compose.dev.yml @@ -4,7 +4,7 @@ services: mysql: image: mariadb environment: - - MYSQL_ROOT_PASSWORD=root + - MYSQL_ROOT_PASSWORD=commafeed - MYSQL_DATABASE=commafeed ports: - "3306:3306" @@ -12,8 +12,8 @@ services: postgresql: image: postgres environment: - - POSTGRES_USER=root - - POSTGRES_PASSWORD=root + - POSTGRES_USER=commafeed + - POSTGRES_PASSWORD=commafeed - POSTGRES_DB=commafeed ports: - "5432:5432" diff --git a/commafeed-server/pom.xml b/commafeed-server/pom.xml index 8522cfad..270bdd1c 100644 --- a/commafeed-server/pom.xml +++ b/commafeed-server/pom.xml @@ -6,34 +6,26 @@ com.commafeed commafeed - 4.6.0 + 5.0.0-beta commafeed-server CommaFeed Server - 7.0.0 + 3.13.1 6.6 2.1.0 - 1.78.1 + 1.2.1 - 1.20.1 - - 16.4 - - 9.0.1 - - 11.4.3 - - 7.4.0 + h2 - io.dropwizard - dropwizard-dependencies - 4.0.7 + io.quarkus.platform + quarkus-bom + ${quarkus.version} pom import @@ -41,26 +33,80 @@ - commafeed - - - src/test/resources - false - - - src/test/resources - - docker-images.properties - - true - - - + + + kr.motd.maven + os-maven-plugin + 1.7.1 + + + + maven-help-plugin + 3.4.1 + + + initialize + + active-profiles + + + + + + + io.quarkus.platform + quarkus-maven-plugin + ${quarkus.version} + true + + + + build + generate-code + generate-code-tests + native-image-agent + + + + commafeed-${project.version} + + -${quarkus.datasource.db-kind}-${os.detected.name}-${os.detected.arch}-runner + + + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.7.1 + + + package + + single + + + commafeed-${project.version}-${quarkus.datasource.db-kind}-jvm + false + + src/main/assembly/zip-quarkus-app.xml + + + + + org.apache.maven.plugins maven-surefire-plugin 3.3.1 + + + org.jboss.logmanager.LogManager + + org.apache.maven.plugins @@ -74,6 +120,13 @@ + + + ${project.build.directory}/${project.build.finalName}-runner + + org.jboss.logmanager.LogManager + + io.github.git-commit-id @@ -94,63 +147,13 @@ false - - org.apache.maven.plugins - maven-shade-plugin - 3.6.0 - - - org.kordamp.shade - maven-shade-ext-transformers - 1.4.0 - - - - false - - - *:* - - module-info.class - META-INF/*.SF - META-INF/*.DSA - META-INF/*.RSA - - - - - - - package - - shade - - - - - - com.commafeed.CommaFeedApplication - - - - rome.properties - - append - - - - - - io.swagger.core.v3 swagger-maven-plugin-jakarta 2.2.22 - ${project.build.directory}/classes/assets + ${project.build.directory}/classes/META-INF/resources JSONANDYAML com.commafeed.frontend.resource @@ -168,18 +171,6 @@ - - org.apache.maven.plugins - maven-jar-plugin - 3.4.2 - - - - true - - - - org.apache.maven.plugins maven-checkstyle-plugin @@ -237,7 +228,7 @@ com.commafeed commafeed-client - 4.6.0 + 5.0.0-beta @@ -247,60 +238,69 @@ provided - org.slf4j - slf4j-api - - - org.slf4j - jcl-over-slf4j + org.kohsuke.metainf-services + metainf-services + 1.11 + provided - com.google.inject - guice - ${guice.version} + 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.quarkus + quarkus-jdbc-h2 + + + io.quarkus + quarkus-jdbc-mysql + + + io.quarkus + quarkus-jdbc-mariadb + + + io.quarkus + quarkus-jdbc-postgresql - - io.dropwizard - dropwizard-core - - - io.dropwizard - dropwizard-unix-socket - - - io.dropwizard - dropwizard-hibernate - - - io.dropwizard - dropwizard-migrations - - - io.dropwizard - dropwizard-assets - - - io.dropwizard - dropwizard-forms - - - io.dropwizard.metrics - metrics-graphite - io.dropwizard.metrics metrics-json - - - io.whitfin - dropwizard-environment-substitutor - 1.1.1 - - - org.eclipse.jetty.websocket - websocket-jakarta-server + 4.2.26 @@ -349,17 +349,6 @@ 1.6.4 - - redis.clients - jedis - 5.1.4 - - - com.sun.mail - jakarta.mail - 2.0.1 - - com.rometools rome @@ -409,7 +398,15 @@ org.apache.httpcomponents.client5 httpclient5 + 5.3.1 + + + org.brotli + dec + 0.1.2 + + io.github.hakky54 sslcontext-kickstart-for-apache5 @@ -423,35 +420,8 @@ - com.h2database - h2 - 2.3.232 - - - com.manticore-projects.tools - h2migrationtool - 1.7 - - - - com.mysql - mysql-connector-j - 9.0.0 - - - org.mariadb.jdbc - mariadb-java-client - 3.4.1 - - - org.postgresql - postgresql - 42.7.3 - - - - org.junit.jupiter - junit-jupiter-engine + io.quarkus + quarkus-junit5 test @@ -464,37 +434,15 @@ mockito-junit-jupiter test - org.mock-server mockserver-junit-jupiter 5.15.0 test - - - org.bouncycastle - bcprov-jdk18on - ${bouncycastle.version} - test - - - org.bouncycastle - bcpkix-jdk18on - ${bouncycastle.version} - test - - - - com.icegreen - greenmail-junit5 - 2.0.1 - test - - - io.dropwizard - dropwizard-testing + io.rest-assured + rest-assured test @@ -508,30 +456,138 @@ 1.46.0 test + + org.reflections + reflections + 0.10.2 + test + - - org.testcontainers - testcontainers - ${testcontainers.version} - test - - - org.testcontainers - postgresql - ${testcontainers.version} - test - - - org.testcontainers - mysql - ${testcontainers.version} - test - - - org.testcontainers - mariadb - ${testcontainers.version} - test - + + + + native + + + native + + + + true + + + + + h2 + + true + + + + + org.codehaus.mojo + properties-maven-plugin + ${properties-plugin.version} + + + + set-system-properties + + + + + + + quarkus.datasource.db-kind + h2 + + + + + + + + + mysql + + + + org.codehaus.mojo + properties-maven-plugin + ${properties-plugin.version} + + + + set-system-properties + + + + + + + quarkus.datasource.db-kind + mysql + + + + + + + + + mariadb + + + + org.codehaus.mojo + properties-maven-plugin + ${properties-plugin.version} + + + + set-system-properties + + + + + + + quarkus.datasource.db-kind + mariadb + + + + + + + + + postgresql + + + + org.codehaus.mojo + properties-maven-plugin + ${properties-plugin.version} + + + + set-system-properties + + + + + + + quarkus.datasource.db-kind + postgresql + + + + + + + + diff --git a/commafeed-server/src/main/assembly/zip-quarkus-app.xml b/commafeed-server/src/main/assembly/zip-quarkus-app.xml new file mode 100644 index 00000000..e35ab082 --- /dev/null +++ b/commafeed-server/src/main/assembly/zip-quarkus-app.xml @@ -0,0 +1,21 @@ + + + zip-quarkus-app + + true + commafeed-${project.version}-${quarkus.datasource.db-kind} + + + zip + + + + ${project.build.directory}/quarkus-app + / + + **/* + + + + \ No newline at end of file diff --git a/commafeed-server/src/main/docker/Dockerfile.jvm b/commafeed-server/src/main/docker/Dockerfile.jvm new file mode 100644 index 00000000..043eb572 --- /dev/null +++ b/commafeed-server/src/main/docker/Dockerfile.jvm @@ -0,0 +1,16 @@ +FROM ibm-semeru-runtimes:open-21.0.3_9-jre + +EXPOSE 8082 + +RUN mkdir -p /commafeed/data +VOLUME /commafeed/data + +COPY commafeed-server/target/quarkus-app/ /commafeed +WORKDIR /commafeed + +CMD ["java", \ + "-Xtune:virtualized", \ + "-Xminf0.05", \ + "-Xmaxf0.1", \ + "-jar", \ + "quarkus-run.jar"] diff --git a/commafeed-server/src/main/docker/Dockerfile.native b/commafeed-server/src/main/docker/Dockerfile.native new file mode 100644 index 00000000..f66f706f --- /dev/null +++ b/commafeed-server/src/main/docker/Dockerfile.native @@ -0,0 +1,9 @@ +FROM debian:12.6 +EXPOSE 8082 + +RUN mkdir -p /commafeed/data +VOLUME /commafeed/data + +COPY commafeed-server/target/commafeed-*-runner /commafeed/application +WORKDIR /commafeed +CMD ["./application"] diff --git a/commafeed-server/src/main/docker/README.md b/commafeed-server/src/main/docker/README.md new file mode 100644 index 00000000..5f7ed37d --- /dev/null +++ b/commafeed-server/src/main/docker/README.md @@ -0,0 +1,86 @@ +# CommaFeed + +Official docker images for https://github.com/Athou/commafeed/ + +## Quickstart + +Start CommaFeed with an 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 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 different docker tag): + +``` +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 +``` + +## Configuration + +All [CommaFeed settings](https://github.com/Athou/commafeed/blob/master/commafeed-server/src/main/java/com/commafeed/CommaFeedConfiguration.java) +are optional and have sensible default values. + +Settings are overrideable with environment variables. For instance, `config.feedRefresh().intervalEmpirical()` can be +set +with the `COMMAFEED_FEED_REFRESH_INTERVAL_EMPIRICAL=true` 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` property to a fixed value (min. 16 characters). + +## Docker tags + +Tags are of the form `-[-jvm]` where: + +- `` is either: + - a specific CommaFeed version (e.g. `4.6.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. This image supports + the + arm64 platform which is not yet supported by the native image. \ No newline at end of file diff --git a/commafeed-server/src/main/java/com/commafeed/CommaFeedApplication.java b/commafeed-server/src/main/java/com/commafeed/CommaFeedApplication.java index 4bd7183f..e404fb90 100644 --- a/commafeed-server/src/main/java/com/commafeed/CommaFeedApplication.java +++ b/commafeed-server/src/main/java/com/commafeed/CommaFeedApplication.java @@ -1,277 +1,40 @@ package com.commafeed; -import java.io.IOException; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.Instant; -import java.util.EnumSet; -import java.util.Set; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -import org.eclipse.jetty.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer; -import org.hibernate.cfg.AvailableSettings; - -import com.codahale.metrics.json.MetricsModule; -import com.commafeed.backend.dao.UserDAO; import com.commafeed.backend.feed.FeedRefreshEngine; -import com.commafeed.backend.model.AbstractModel; -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.FeedEntryTag; -import com.commafeed.backend.model.FeedSubscription; -import com.commafeed.backend.model.User; -import com.commafeed.backend.model.UserRole; -import com.commafeed.backend.model.UserSettings; -import com.commafeed.backend.service.UserService; import com.commafeed.backend.service.db.DatabaseStartupService; -import com.commafeed.backend.service.db.H2MigrationService; -import com.commafeed.backend.task.ScheduledTask; -import com.commafeed.frontend.auth.PasswordConstraintValidator; -import com.commafeed.frontend.auth.SecurityCheckFactoryProvider; -import com.commafeed.frontend.resource.AdminREST; -import com.commafeed.frontend.resource.CategoryREST; -import com.commafeed.frontend.resource.EntryREST; -import com.commafeed.frontend.resource.FeedREST; -import com.commafeed.frontend.resource.ServerREST; -import com.commafeed.frontend.resource.UserREST; -import com.commafeed.frontend.resource.fever.FeverREST; -import com.commafeed.frontend.servlet.CustomCssServlet; -import com.commafeed.frontend.servlet.CustomJsServlet; -import com.commafeed.frontend.servlet.LogoutServlet; -import com.commafeed.frontend.servlet.NextUnreadServlet; -import com.commafeed.frontend.servlet.RobotsTxtDisallowAllServlet; -import com.commafeed.frontend.session.SessionHelperFactoryProvider; -import com.commafeed.frontend.ws.WebSocketConfigurator; -import com.commafeed.frontend.ws.WebSocketEndpoint; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.MapperFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.google.inject.Guice; -import com.google.inject.Injector; -import com.google.inject.Key; -import com.google.inject.TypeLiteral; +import com.commafeed.backend.task.TaskScheduler; +import com.commafeed.security.password.PasswordConstraintValidator; -import io.dropwizard.assets.AssetsBundle; -import io.dropwizard.configuration.DefaultConfigurationFactoryFactory; -import io.dropwizard.configuration.EnvironmentVariableSubstitutor; -import io.dropwizard.configuration.SubstitutingSourceProvider; -import io.dropwizard.core.Application; -import io.dropwizard.core.ConfiguredBundle; -import io.dropwizard.core.setup.Bootstrap; -import io.dropwizard.core.setup.Environment; -import io.dropwizard.db.DataSourceFactory; -import io.dropwizard.forms.MultiPartBundle; -import io.dropwizard.hibernate.HibernateBundle; -import io.dropwizard.migrations.MigrationsBundle; -import io.dropwizard.servlets.CacheBustingFilter; -import io.whitfin.dropwizard.configuration.EnvironmentSubstitutor; -import jakarta.servlet.DispatcherType; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.ServletRequest; -import jakarta.servlet.ServletResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.websocket.server.ServerEndpointConfig; +import io.quarkus.runtime.ShutdownEvent; +import io.quarkus.runtime.StartupEvent; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Singleton; +import lombok.RequiredArgsConstructor; -public class CommaFeedApplication extends Application { +@Singleton +@RequiredArgsConstructor +public class CommaFeedApplication { public static final String USERNAME_ADMIN = "admin"; public static final String USERNAME_DEMO = "demo"; - public static final Instant STARTUP_TIME = Instant.now(); + private final DatabaseStartupService databaseStartupService; + private final FeedRefreshEngine feedRefreshEngine; + private final TaskScheduler taskScheduler; + private final CommaFeedConfiguration config; - private HibernateBundle hibernateBundle; + public void start(@Observes StartupEvent ev) { + PasswordConstraintValidator.setStrict(config.users().strictPasswordPolicy()); - @Override - public String getName() { - return "CommaFeed"; + databaseStartupService.populateInitialData(); + + feedRefreshEngine.start(); + taskScheduler.start(); } - @Override - public void initialize(Bootstrap bootstrap) { - configureEnvironmentSubstitutor(bootstrap); - configureObjectMapper(bootstrap.getObjectMapper()); - - // run h2 migration as the first bundle because we need to migrate before hibernate is initialized - bootstrap.addBundle(new ConfiguredBundle<>() { - @Override - public void run(CommaFeedConfiguration config, Environment environment) { - DataSourceFactory dataSourceFactory = config.getDataSourceFactory(); - String url = dataSourceFactory.getUrl(); - if (isFileBasedH2(url)) { - Path path = getFilePath(url); - String user = dataSourceFactory.getUser(); - String password = dataSourceFactory.getPassword(); - new H2MigrationService().migrateIfNeeded(path, user, password); - } - } - - private boolean isFileBasedH2(String url) { - return url.startsWith("jdbc:h2:") && !url.startsWith("jdbc:h2:mem:"); - } - - private Path getFilePath(String url) { - String name = url.substring("jdbc:h2:".length()).split(";")[0]; - return Paths.get(name + ".mv.db"); - } - }); - - bootstrap.addBundle(hibernateBundle = new HibernateBundle<>(AbstractModel.class, Feed.class, FeedCategory.class, FeedEntry.class, - FeedEntryContent.class, FeedEntryStatus.class, FeedEntryTag.class, FeedSubscription.class, User.class, UserRole.class, - UserSettings.class) { - @Override - public DataSourceFactory getDataSourceFactory(CommaFeedConfiguration configuration) { - DataSourceFactory factory = configuration.getDataSourceFactory(); - - factory.getProperties().put(AvailableSettings.PREFERRED_POOLED_OPTIMIZER, "pooled-lo"); - - factory.getProperties().put(AvailableSettings.STATEMENT_BATCH_SIZE, "50"); - factory.getProperties().put(AvailableSettings.BATCH_VERSIONED_DATA, "true"); - factory.getProperties().put(AvailableSettings.ORDER_INSERTS, "true"); - factory.getProperties().put(AvailableSettings.ORDER_UPDATES, "true"); - return factory; - } - }); - - bootstrap.addBundle(new MigrationsBundle<>() { - @Override - public DataSourceFactory getDataSourceFactory(CommaFeedConfiguration configuration) { - return configuration.getDataSourceFactory(); - } - }); - - bootstrap.addBundle(new AssetsBundle("/assets/", "/", "index.html")); - bootstrap.addBundle(new MultiPartBundle()); + public void stop(@Observes ShutdownEvent ev) { + feedRefreshEngine.stop(); + taskScheduler.stop(); } - private static void configureEnvironmentSubstitutor(Bootstrap bootstrap) { - bootstrap.setConfigurationFactoryFactory(new DefaultConfigurationFactoryFactory<>() { - @Override - protected ObjectMapper configureObjectMapper(ObjectMapper objectMapper) { - // disable case sensitivity because EnvironmentSubstitutor maps MYPROPERTY to myproperty and not to myProperty - return objectMapper - .setConfig(objectMapper.getDeserializationConfig().with(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES)); - } - }); - - bootstrap.setConfigurationSourceProvider(buildEnvironmentSubstitutor(bootstrap)); - } - - private static void configureObjectMapper(ObjectMapper objectMapper) { - // read and write instants as milliseconds instead of nanoseconds - objectMapper.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)); - } - - private static EnvironmentSubstitutor buildEnvironmentSubstitutor(Bootstrap bootstrap) { - // enable config.yml string substitution - // e.g. having a custom config.yml file with app.session.path=${SOME_ENV_VAR} will substitute SOME_ENV_VAR - SubstitutingSourceProvider substitutingSourceProvider = new SubstitutingSourceProvider(bootstrap.getConfigurationSourceProvider(), - new EnvironmentVariableSubstitutor(false)); - - // enable config.yml properties override with env variables prefixed with CF_ - // e.g. setting CF_APP_ALLOWREGISTRATIONS=true will set app.allowRegistrations to true - return new EnvironmentSubstitutor("CF", substitutingSourceProvider); - } - - @Override - public void run(CommaFeedConfiguration config, Environment environment) { - PasswordConstraintValidator.setStrict(config.getApplicationSettings().getStrictPasswordPolicy()); - - // guice init - Injector injector = Guice.createInjector(new CommaFeedModule(hibernateBundle.getSessionFactory(), config, environment.metrics())); - - // session management - environment.servlets().setSessionHandler(config.getSessionHandlerFactory().build(config.getDataSourceFactory())); - - // support for "@SecurityCheck User user" injection - environment.jersey() - .register(new SecurityCheckFactoryProvider.Binder(injector.getInstance(UserDAO.class), - injector.getInstance(UserService.class), config)); - // support for "@Context SessionHelper sessionHelper" injection - environment.jersey().register(new SessionHelperFactoryProvider.Binder()); - - // REST resources - environment.jersey().setUrlPattern("/rest/*"); - environment.jersey().register(injector.getInstance(AdminREST.class)); - environment.jersey().register(injector.getInstance(CategoryREST.class)); - environment.jersey().register(injector.getInstance(EntryREST.class)); - environment.jersey().register(injector.getInstance(FeedREST.class)); - environment.jersey().register(injector.getInstance(ServerREST.class)); - environment.jersey().register(injector.getInstance(UserREST.class)); - environment.jersey().register(injector.getInstance(FeverREST.class)); - - // Servlets - environment.servlets().addServlet("next", injector.getInstance(NextUnreadServlet.class)).addMapping("/next"); - environment.servlets().addServlet("logout", injector.getInstance(LogoutServlet.class)).addMapping("/logout"); - environment.servlets().addServlet("customCss", injector.getInstance(CustomCssServlet.class)).addMapping("/custom_css.css"); - environment.servlets().addServlet("customJs", injector.getInstance(CustomJsServlet.class)).addMapping("/custom_js.js"); - if (Boolean.TRUE.equals(config.getApplicationSettings().getHideFromWebCrawlers())) { - environment.servlets() - .addServlet("robots.txt", injector.getInstance(RobotsTxtDisallowAllServlet.class)) - .addMapping("/robots.txt"); - } - - // WebSocket endpoint - JakartaWebSocketServletContainerInitializer.configure(environment.getApplicationContext(), (context, container) -> { - container.setDefaultMaxSessionIdleTimeout(config.getApplicationSettings().getWebsocketPingInterval().toMilliseconds() + 10000); - - container.addEndpoint(ServerEndpointConfig.Builder.create(WebSocketEndpoint.class, "/ws") - .configurator(injector.getInstance(WebSocketConfigurator.class)) - .build()); - }); - - // Scheduled tasks - Set tasks = injector.getInstance(Key.get(new TypeLiteral<>() { - })); - ScheduledExecutorService executor = environment.lifecycle() - .scheduledExecutorService("task-scheduler", true) - .threads(tasks.size()) - .build(); - for (ScheduledTask task : tasks) { - task.register(executor); - } - - // database init/changelogs - environment.lifecycle().manage(injector.getInstance(DatabaseStartupService.class)); - - // start feed fetching engine - environment.lifecycle().manage(injector.getInstance(FeedRefreshEngine.class)); - - // prevent caching index.html, so that the webapp is always up to date - environment.servlets() - .addFilter("index-cache-busting-filter", new CacheBustingFilter()) - .addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/"); - - // prevent caching openapi files, so that the documentation is always up to date - environment.servlets() - .addFilter("openapi-cache-busting-filter", new CacheBustingFilter()) - .addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/openapi.json", "/openapi.yaml"); - - // prevent caching REST resources, except for favicons - environment.servlets().addFilter("rest-cache-busting-filter", new CacheBustingFilter() { - @Override - public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { - String path = ((HttpServletRequest) request).getRequestURI(); - if (path.contains("/feed/favicon")) { - chain.doFilter(request, response); - } else { - super.doFilter(request, response, chain); - } - } - }).addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/rest/*"); - - } - - public static void main(String[] args) throws Exception { - new CommaFeedApplication().run(args); - } } diff --git a/commafeed-server/src/main/java/com/commafeed/CommaFeedConfiguration.java b/commafeed-server/src/main/java/com/commafeed/CommaFeedConfiguration.java index 845c01cd..cc016038 100644 --- a/commafeed-server/src/main/java/com/commafeed/CommaFeedConfiguration.java +++ b/commafeed-server/src/main/java/com/commafeed/CommaFeedConfiguration.java @@ -1,188 +1,275 @@ package com.commafeed; -import java.io.IOException; -import java.io.InputStream; +import java.time.Duration; import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.Properties; +import java.util.Optional; -import com.commafeed.backend.cache.RedisPoolFactory; -import com.commafeed.frontend.session.SessionHandlerFactory; -import com.fasterxml.jackson.annotation.JsonProperty; +import com.commafeed.backend.feed.FeedRefreshIntervalCalculator; -import io.dropwizard.core.Configuration; -import io.dropwizard.db.DataSourceFactory; -import io.dropwizard.util.DataSize; -import io.dropwizard.util.Duration; -import jakarta.validation.Valid; +import io.quarkus.runtime.configuration.MemorySize; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; -import lombok.Getter; -import lombok.Setter; -@Getter -@Setter -public class CommaFeedConfiguration extends Configuration { +/** + * CommaFeed configuration + * + * Default values are for production, they can be overridden in application.properties for other profiles + */ +@ConfigMapping(prefix = "commafeed") +public interface CommaFeedConfiguration { + /** + * Whether to expose a robots.txt file that disallows web crawlers and search engine indexers. + */ + @WithDefault("true") + boolean hideFromWebCrawlers(); - public enum CacheType { - NOOP, REDIS + /** + * 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 + */ + HttpClient httpClient(); + + /** + * Feed refresh engine settings. + */ + FeedRefresh feedRefresh(); + + /** + * Database settings. + */ + Database database(); + + /** + * Users settings. + */ + Users users(); + + /** + * Websocket settings. + */ + 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(); } - @Valid - @NotNull - @JsonProperty("database") - private final DataSourceFactory dataSourceFactory = new DataSourceFactory(); + interface FeedRefresh { + /** + * Amount of time CommaFeed will wait before refreshing the same feed. + */ + @WithDefault("5m") + Duration interval(); - @Valid - @NotNull - @JsonProperty("redis") - private final RedisPoolFactory redisPoolFactory = new RedisPoolFactory(); + /** + * If true, CommaFeed will calculate the next refresh time based on the feed's average time between entries and the time since the + * last entry was published. The interval will be somewhere between the default refresh interval and 24h. + * + * See {@link FeedRefreshIntervalCalculator} for details. + */ + @WithDefault("false") + boolean intervalEmpirical(); - @Valid - @NotNull - @JsonProperty("session") - private final SessionHandlerFactory sessionHandlerFactory = new SessionHandlerFactory(); + /** + * Amount of http threads used to fetch feeds. + */ + @Min(1) + @WithDefault("3") + int httpThreads(); - @Valid - @NotNull - @JsonProperty("app") - private ApplicationSettings applicationSettings; + /** + * Amount of threads used to insert new entries in the database. + */ + @Min(1) + @WithDefault("1") + int databaseThreads(); - private final String version; - private final String gitCommit; + /** + * 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(); - public CommaFeedConfiguration() { - Properties properties = new Properties(); - try (InputStream stream = getClass().getResourceAsStream("/git.properties")) { - if (stream != null) { - properties.load(stream); + /** + * 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(); + } + + interface Database { + /** + * Database query timeout. + * + * 0 to disable. + */ + @WithDefault("0") + Duration queryTimeout(); + + 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; } - } catch (IOException e) { - throw new RuntimeException(e); } - - this.version = properties.getProperty("git.build.version", "unknown"); - this.gitCommit = properties.getProperty("git.commit.id.abbrev", "unknown"); } - @Getter - @Setter - public static class ApplicationSettings { - @NotNull - @NotBlank - @Valid - private String publicUrl; + interface Users { + /** + * Whether to let users create accounts for themselves. + */ + @WithDefault("false") + boolean allowRegistrations(); - @NotNull - @Valid - private Boolean hideFromWebCrawlers = true; + /** + * Whether to enable strict password validation (1 uppercase char, 1 lowercase char, 1 digit, 1 special char). + */ + @WithDefault("true") + boolean strictPasswordPolicy(); - @NotNull - @Valid - private Boolean allowRegistrations; + /** + * Whether to create a demo account the first time the app starts. + */ + @WithDefault("false") + boolean createDemoAccount(); + } - @NotNull - @Valid - private Boolean strictPasswordPolicy = true; + interface Websocket { + /** + * Enable websocket connection so the server can notify web clients that there are new entries for feeds. + */ + @WithDefault("true") + boolean enabled(); - @NotNull - @Valid - private Boolean createDemoAccount; - - private String googleAnalyticsTrackingCode; - - private String googleAuthKey; - - @NotNull - @Min(1) - @Valid - private Integer backgroundThreads; - - @NotNull - @Min(1) - @Valid - private Integer databaseUpdateThreads; - - @NotNull - @Positive - @Valid - private Integer databaseCleanupBatchSize = 100; - - private String smtpHost; - private int smtpPort; - private boolean smtpTls; - private String smtpUserName; - private String smtpPassword; - private String smtpFromAddress; - - private boolean graphiteEnabled; - private String graphitePrefix; - private String graphiteHost; - private int graphitePort; - private int graphiteInterval; - - @NotNull - @Valid - private Boolean heavyLoad; - - @NotNull - @Valid - private Boolean imageProxyEnabled; - - @NotNull - @Min(0) - @Valid - private Integer queryTimeout; - - @NotNull - @Min(0) - @Valid - private Integer keepStatusDays; - - @NotNull - @Min(0) - @Valid - private Integer maxFeedCapacity; - - @NotNull - @Min(0) - @Valid - private Integer maxEntriesAgeDays = 0; - - @NotNull - @Valid - private Integer maxFeedsPerUser = 0; - - @NotNull - @Valid - private DataSize maxFeedResponseSize = DataSize.megabytes(5); - - @NotNull - @Min(0) - @Valid - private Integer refreshIntervalMinutes; - - @NotNull - @Valid - private CacheType cache; - - @Valid - private String announcement; - - private String userAgent; - - private Boolean websocketEnabled = true; - - private Duration websocketPingInterval = Duration.minutes(15); - - private Duration treeReloadInterval = Duration.seconds(30); - - public Instant getUnreadThreshold() { - return getKeepStatusDays() > 0 ? Instant.now().minus(getKeepStatusDays(), ChronoUnit.DAYS) : null; - } + /** + * 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/CommaFeedModule.java b/commafeed-server/src/main/java/com/commafeed/CommaFeedModule.java deleted file mode 100644 index 945aa5b7..00000000 --- a/commafeed-server/src/main/java/com/commafeed/CommaFeedModule.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.commafeed; - -import java.net.InetSocketAddress; -import java.util.concurrent.TimeUnit; - -import org.hibernate.SessionFactory; - -import com.codahale.metrics.MetricFilter; -import com.codahale.metrics.MetricRegistry; -import com.codahale.metrics.graphite.Graphite; -import com.codahale.metrics.graphite.GraphiteReporter; -import com.commafeed.CommaFeedConfiguration.ApplicationSettings; -import com.commafeed.CommaFeedConfiguration.CacheType; -import com.commafeed.backend.cache.CacheService; -import com.commafeed.backend.cache.NoopCacheService; -import com.commafeed.backend.cache.RedisCacheService; -import com.commafeed.backend.favicon.AbstractFaviconFetcher; -import com.commafeed.backend.favicon.DefaultFaviconFetcher; -import com.commafeed.backend.favicon.FacebookFaviconFetcher; -import com.commafeed.backend.favicon.YoutubeFaviconFetcher; -import com.commafeed.backend.task.DemoAccountCleanupTask; -import com.commafeed.backend.task.EntriesExceedingFeedCapacityCleanupTask; -import com.commafeed.backend.task.OldEntriesCleanupTask; -import com.commafeed.backend.task.OldStatusesCleanupTask; -import com.commafeed.backend.task.OrphanedContentsCleanupTask; -import com.commafeed.backend.task.OrphanedFeedsCleanupTask; -import com.commafeed.backend.task.ScheduledTask; -import com.commafeed.backend.urlprovider.FeedURLProvider; -import com.commafeed.backend.urlprovider.InPageReferenceFeedURLProvider; -import com.commafeed.backend.urlprovider.YoutubeFeedURLProvider; -import com.google.inject.AbstractModule; -import com.google.inject.Provides; -import com.google.inject.multibindings.Multibinder; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@RequiredArgsConstructor -@Slf4j -public class CommaFeedModule extends AbstractModule { - - @Getter(onMethod = @__({ @Provides })) - private final SessionFactory sessionFactory; - - @Getter(onMethod = @__({ @Provides })) - private final CommaFeedConfiguration config; - - @Getter(onMethod = @__({ @Provides })) - private final MetricRegistry metrics; - - @Override - protected void configure() { - CacheService cacheService = config.getApplicationSettings().getCache() == CacheType.NOOP ? new NoopCacheService() - : new RedisCacheService(config.getRedisPoolFactory().build()); - log.info("using cache {}", cacheService.getClass()); - bind(CacheService.class).toInstance(cacheService); - - Multibinder faviconMultibinder = Multibinder.newSetBinder(binder(), AbstractFaviconFetcher.class); - faviconMultibinder.addBinding().to(YoutubeFaviconFetcher.class); - faviconMultibinder.addBinding().to(FacebookFaviconFetcher.class); - faviconMultibinder.addBinding().to(DefaultFaviconFetcher.class); - - Multibinder urlProviderMultibinder = Multibinder.newSetBinder(binder(), FeedURLProvider.class); - urlProviderMultibinder.addBinding().to(InPageReferenceFeedURLProvider.class); - urlProviderMultibinder.addBinding().to(YoutubeFeedURLProvider.class); - - Multibinder taskMultibinder = Multibinder.newSetBinder(binder(), ScheduledTask.class); - taskMultibinder.addBinding().to(OldStatusesCleanupTask.class); - taskMultibinder.addBinding().to(EntriesExceedingFeedCapacityCleanupTask.class); - taskMultibinder.addBinding().to(OldEntriesCleanupTask.class); - taskMultibinder.addBinding().to(OrphanedFeedsCleanupTask.class); - taskMultibinder.addBinding().to(OrphanedContentsCleanupTask.class); - taskMultibinder.addBinding().to(DemoAccountCleanupTask.class); - - ApplicationSettings settings = config.getApplicationSettings(); - - if (settings.isGraphiteEnabled()) { - final String graphitePrefix = settings.getGraphitePrefix(); - final String graphiteHost = settings.getGraphiteHost(); - final int graphitePort = settings.getGraphitePort(); - final int graphiteInterval = settings.getGraphiteInterval(); - - log.info("Graphite Metrics will be sent to host={}, port={}, prefix={}, interval={}sec", graphiteHost, graphitePort, - graphitePrefix, graphiteInterval); - - final Graphite graphite = new Graphite(new InetSocketAddress(graphiteHost, graphitePort)); - final GraphiteReporter reporter = GraphiteReporter.forRegistry(metrics) - .prefixedWith(graphitePrefix) - .convertRatesTo(TimeUnit.SECONDS) - .convertDurationsTo(TimeUnit.MILLISECONDS) - .filter(MetricFilter.ALL) - .build(graphite); - reporter.start(graphiteInterval, TimeUnit.SECONDS); - } - } -} diff --git a/commafeed-server/src/main/java/com/commafeed/CommaFeedProducers.java b/commafeed-server/src/main/java/com/commafeed/CommaFeedProducers.java new file mode 100644 index 00000000..7ab50216 --- /dev/null +++ b/commafeed-server/src/main/java/com/commafeed/CommaFeedProducers.java @@ -0,0 +1,16 @@ +package com.commafeed; + +import com.codahale.metrics.MetricRegistry; + +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Singleton; + +@Singleton +public class CommaFeedProducers { + + @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 new file mode 100644 index 00000000..63188ed5 --- /dev/null +++ b/commafeed-server/src/main/java/com/commafeed/CommaFeedVersion.java @@ -0,0 +1,31 @@ +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 new file mode 100644 index 00000000..4519743c --- /dev/null +++ b/commafeed-server/src/main/java/com/commafeed/ExceptionMappers.java @@ -0,0 +1,49 @@ +package com.commafeed; + +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 jakarta.annotation.Priority; +import jakarta.validation.ValidationException; +import jakarta.ws.rs.ext.Provider; +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 new file mode 100644 index 00000000..0bc56613 --- /dev/null +++ b/commafeed-server/src/main/java/com/commafeed/JacksonCustomizer.java @@ -0,0 +1,27 @@ +package com.commafeed; + +import java.util.concurrent.TimeUnit; + +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; +import jakarta.inject.Singleton; + +@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_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 new file mode 100644 index 00000000..278f83e3 --- /dev/null +++ b/commafeed-server/src/main/java/com/commafeed/NativeImageClasses.java @@ -0,0 +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 { +} 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 c7bc6155..61ac226e 100644 --- a/commafeed-server/src/main/java/com/commafeed/backend/HttpGetter.java +++ b/commafeed-server/src/main/java/com/commafeed/backend/HttpGetter.java @@ -3,14 +3,15 @@ package com.commafeed.backend; import java.io.IOException; import java.io.InputStream; import java.net.URI; +import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.concurrent.TimeUnit; import org.apache.commons.lang3.StringUtils; 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; @@ -21,21 +22,20 @@ import org.apache.hc.client5.http.protocol.RedirectLocations; 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.eclipse.jetty.http.HttpStatus; import com.codahale.metrics.MetricRegistry; import com.commafeed.CommaFeedConfiguration; +import com.commafeed.CommaFeedVersion; import com.google.common.collect.Iterables; import com.google.common.io.ByteStreams; import com.google.common.net.HttpHeaders; -import io.dropwizard.util.DataSize; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -50,16 +50,18 @@ import nl.altindag.ssl.apache5.util.Apache5SslUtils; @Slf4j public class HttpGetter { + private final CommaFeedConfiguration config; private final CloseableHttpClient client; - private final DataSize maxResponseSize; - @Inject - public HttpGetter(CommaFeedConfiguration config, MetricRegistry metrics) { - PoolingHttpClientConnectionManager connectionManager = newConnectionManager(config.getApplicationSettings().getBackgroundThreads()); - String userAgent = Optional.ofNullable(config.getApplicationSettings().getUserAgent()) - .orElseGet(() -> String.format("CommaFeed/%s (https://github.com/Athou/commafeed)", config.getVersion())); - this.client = newClient(connectionManager, userAgent); - this.maxResponseSize = config.getApplicationSettings().getMaxFeedResponseSize(); + public HttpGetter(CommaFeedConfiguration config, CommaFeedVersion version, MetricRegistry metrics) { + this.config = config; + + 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()); metrics.registerGauge(MetricRegistry.name(getClass(), "pool", "max"), () -> connectionManager.getTotalStats().getMax()); metrics.registerGauge(MetricRegistry.name(getClass(), "pool", "size"), @@ -68,8 +70,8 @@ public class HttpGetter { metrics.registerGauge(MetricRegistry.name(getClass(), "pool", "pending"), () -> connectionManager.getTotalStats().getPending()); } - public HttpResult getBinary(String url, int timeout) throws IOException, NotModifiedException { - return getBinary(url, null, null, timeout); + public HttpResult getBinary(String url) throws IOException, NotModifiedException { + return getBinary(url, null, null); } /** @@ -82,10 +84,9 @@ public class HttpGetter { * @throws NotModifiedException * if the url hasn't changed since we asked for it last time */ - public HttpResult getBinary(String url, String lastModified, String eTag, int timeout) throws IOException, NotModifiedException { + public HttpResult getBinary(String url, String lastModified, String eTag) throws IOException, NotModifiedException { log.debug("fetching {}", url); - long start = System.currentTimeMillis(); ClassicHttpRequest request = ClassicRequestBuilder.get(url).build(); if (lastModified != null) { request.addHeader(HttpHeaders.IF_MODIFIED_SINCE, lastModified); @@ -95,10 +96,10 @@ public class HttpGetter { } HttpClientContext context = HttpClientContext.create(); - context.setRequestConfig(RequestConfig.custom().setResponseTimeout(timeout, TimeUnit.MILLISECONDS).build()); - + context.setRequestConfig(RequestConfig.custom().setResponseTimeout(Timeout.of(config.httpClient().responseTimeout())).build()); HttpResponse response = client.execute(request, context, resp -> { - byte[] content = resp.getEntity() == null ? null : toByteArray(resp.getEntity(), maxResponseSize.toBytes()); + 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) @@ -120,7 +121,7 @@ public class HttpGetter { }); int code = response.getCode(); - if (code == HttpStatus.NOT_MODIFIED_304) { + if (code == HttpStatus.SC_NOT_MODIFIED) { throw new NotModifiedException("'304 - not modified' http code received"); } else if (code >= 300) { throw new HttpResponseException(code, "Server returned HTTP error code " + code); @@ -136,8 +137,7 @@ public class HttpGetter { throw new NotModifiedException("eTagHeader is the same"); } - long duration = System.currentTimeMillis() - start; - return new HttpResult(response.getContent(), response.getContentType(), lastModifiedHeader, eTagHeader, duration, + return new HttpResult(response.getContent(), response.getContentType(), lastModifiedHeader, eTagHeader, response.getUrlAfterRedirect()); } @@ -160,20 +160,26 @@ public class HttpGetter { } } - private static PoolingHttpClientConnectionManager newConnectionManager(int poolSize) { + private static PoolingHttpClientConnectionManager newConnectionManager(CommaFeedConfiguration config) { SSLFactory sslFactory = SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build(); + int poolSize = config.feedRefresh().httpThreads(); return PoolingHttpClientConnectionManagerBuilder.create() .setSSLSocketFactory(Apache5SslUtils.toSocketFactory(sslFactory)) - .setDefaultConnectionConfig( - ConnectionConfig.custom().setConnectTimeout(Timeout.ofSeconds(5)).setTimeToLive(TimeValue.ofSeconds(30)).build()) + .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) .build(); } - private static CloseableHttpClient newClient(HttpClientConnectionManager connectionManager, String userAgent) { + 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")); @@ -187,7 +193,7 @@ public class HttpGetter { .setDefaultHeaders(headers) .setConnectionManager(connectionManager) .evictExpiredConnections() - .evictIdleConnections(TimeValue.ofMinutes(1)) + .evictIdleConnections(TimeValue.of(idleConnectionsEvictionInterval)) .build(); } @@ -247,7 +253,6 @@ public class HttpGetter { private final String contentType; private final String lastModifiedSince; private final String eTag; - private final long duration; private final String urlAfterRedirect; } diff --git a/commafeed-server/src/main/java/com/commafeed/backend/cache/CacheService.java b/commafeed-server/src/main/java/com/commafeed/backend/cache/CacheService.java deleted file mode 100644 index 106927ef..00000000 --- a/commafeed-server/src/main/java/com/commafeed/backend/cache/CacheService.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.commafeed.backend.cache; - -import java.util.List; -import java.util.Set; - -import com.commafeed.backend.Digests; -import com.commafeed.backend.feed.parser.FeedParserResult.Entry; -import com.commafeed.backend.model.Feed; -import com.commafeed.backend.model.FeedSubscription; -import com.commafeed.backend.model.User; -import com.commafeed.frontend.model.Category; -import com.commafeed.frontend.model.UnreadCount; - -public abstract class CacheService { - - // feed entries for faster refresh - public abstract Set getLastEntries(Feed feed); - - public abstract void setLastEntries(Feed feed, List entries); - - public String buildUniqueEntryKey(Entry entry) { - return Digests.sha1Hex(entry.guid() + entry.url()); - } - - // user categories - public abstract Category getUserRootCategory(User user); - - public abstract void setUserRootCategory(User user, Category category); - - public abstract void invalidateUserRootCategory(User... users); - - // unread count - public abstract UnreadCount getUnreadCount(FeedSubscription sub); - - public abstract void setUnreadCount(FeedSubscription sub, UnreadCount count); - - public abstract void invalidateUnreadCount(FeedSubscription... subs); - -} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/cache/NoopCacheService.java b/commafeed-server/src/main/java/com/commafeed/backend/cache/NoopCacheService.java deleted file mode 100644 index 4b6ed9a0..00000000 --- a/commafeed-server/src/main/java/com/commafeed/backend/cache/NoopCacheService.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.commafeed.backend.cache; - -import java.util.Collections; -import java.util.List; -import java.util.Set; - -import com.commafeed.backend.model.Feed; -import com.commafeed.backend.model.FeedSubscription; -import com.commafeed.backend.model.User; -import com.commafeed.frontend.model.Category; -import com.commafeed.frontend.model.UnreadCount; - -public class NoopCacheService extends CacheService { - - @Override - public Set getLastEntries(Feed feed) { - return Collections.emptySet(); - } - - @Override - public void setLastEntries(Feed feed, List entries) { - } - - @Override - public UnreadCount getUnreadCount(FeedSubscription sub) { - return null; - } - - @Override - public void setUnreadCount(FeedSubscription sub, UnreadCount count) { - - } - - @Override - public void invalidateUnreadCount(FeedSubscription... subs) { - - } - - @Override - public Category getUserRootCategory(User user) { - return null; - } - - @Override - public void setUserRootCategory(User user, Category category) { - - } - - @Override - public void invalidateUserRootCategory(User... users) { - - } - -} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/cache/RedisCacheService.java b/commafeed-server/src/main/java/com/commafeed/backend/cache/RedisCacheService.java deleted file mode 100644 index 2f1e2913..00000000 --- a/commafeed-server/src/main/java/com/commafeed/backend/cache/RedisCacheService.java +++ /dev/null @@ -1,154 +0,0 @@ -package com.commafeed.backend.cache; - -import java.util.List; -import java.util.Set; -import java.util.concurrent.TimeUnit; - -import com.commafeed.backend.model.Feed; -import com.commafeed.backend.model.FeedSubscription; -import com.commafeed.backend.model.Models; -import com.commafeed.backend.model.User; -import com.commafeed.frontend.model.Category; -import com.commafeed.frontend.model.UnreadCount; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import redis.clients.jedis.Jedis; -import redis.clients.jedis.JedisPool; -import redis.clients.jedis.Pipeline; - -@Slf4j -@RequiredArgsConstructor -public class RedisCacheService extends CacheService { - - private static final ObjectMapper MAPPER = new ObjectMapper().registerModule(new JavaTimeModule()); - - private final JedisPool pool; - - @Override - public Set getLastEntries(Feed feed) { - try (Jedis jedis = pool.getResource()) { - String key = buildRedisEntryKey(feed); - return jedis.smembers(key); - } - } - - @Override - public void setLastEntries(Feed feed, List entries) { - try (Jedis jedis = pool.getResource()) { - String key = buildRedisEntryKey(feed); - - Pipeline pipe = jedis.pipelined(); - pipe.del(key); - for (String entry : entries) { - pipe.sadd(key, entry); - } - pipe.expire(key, (int) TimeUnit.DAYS.toSeconds(7)); - pipe.sync(); - } - } - - @Override - public Category getUserRootCategory(User user) { - Category cat = null; - try (Jedis jedis = pool.getResource()) { - String key = buildRedisUserRootCategoryKey(user); - String json = jedis.get(key); - if (json != null) { - cat = MAPPER.readValue(json, Category.class); - } - } catch (Exception e) { - log.error(e.getMessage(), e); - } - return cat; - } - - @Override - public void setUserRootCategory(User user, Category category) { - try (Jedis jedis = pool.getResource()) { - String key = buildRedisUserRootCategoryKey(user); - - Pipeline pipe = jedis.pipelined(); - pipe.del(key); - pipe.set(key, MAPPER.writeValueAsString(category)); - pipe.expire(key, (int) TimeUnit.MINUTES.toSeconds(30)); - pipe.sync(); - } catch (JsonProcessingException e) { - log.error(e.getMessage(), e); - } - } - - @Override - public UnreadCount getUnreadCount(FeedSubscription sub) { - UnreadCount count = null; - try (Jedis jedis = pool.getResource()) { - String key = buildRedisUnreadCountKey(sub); - String json = jedis.get(key); - if (json != null) { - count = MAPPER.readValue(json, UnreadCount.class); - } - } catch (Exception e) { - log.error(e.getMessage(), e); - } - return count; - } - - @Override - public void setUnreadCount(FeedSubscription sub, UnreadCount count) { - try (Jedis jedis = pool.getResource()) { - String key = buildRedisUnreadCountKey(sub); - - Pipeline pipe = jedis.pipelined(); - pipe.del(key); - pipe.set(key, MAPPER.writeValueAsString(count)); - pipe.expire(key, (int) TimeUnit.MINUTES.toSeconds(30)); - pipe.sync(); - } catch (Exception e) { - log.error(e.getMessage(), e); - } - } - - @Override - public void invalidateUserRootCategory(User... users) { - try (Jedis jedis = pool.getResource()) { - Pipeline pipe = jedis.pipelined(); - if (users != null) { - for (User user : users) { - String key = buildRedisUserRootCategoryKey(user); - pipe.del(key); - } - } - pipe.sync(); - } - } - - @Override - public void invalidateUnreadCount(FeedSubscription... subs) { - try (Jedis jedis = pool.getResource()) { - Pipeline pipe = jedis.pipelined(); - if (subs != null) { - for (FeedSubscription sub : subs) { - String key = buildRedisUnreadCountKey(sub); - pipe.del(key); - } - } - pipe.sync(); - } - } - - private String buildRedisEntryKey(Feed feed) { - return "f:" + Models.getId(feed); - } - - private String buildRedisUserRootCategoryKey(User user) { - return "c:" + Models.getId(user); - } - - private String buildRedisUnreadCountKey(FeedSubscription sub) { - return "u:" + Models.getId(sub); - } - -} diff --git a/commafeed-server/src/main/java/com/commafeed/backend/cache/RedisPoolFactory.java b/commafeed-server/src/main/java/com/commafeed/backend/cache/RedisPoolFactory.java deleted file mode 100644 index b74fbf19..00000000 --- a/commafeed-server/src/main/java/com/commafeed/backend/cache/RedisPoolFactory.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.commafeed.backend.cache; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import lombok.Getter; -import redis.clients.jedis.DefaultJedisClientConfig; -import redis.clients.jedis.HostAndPort; -import redis.clients.jedis.JedisClientConfig; -import redis.clients.jedis.JedisPool; -import redis.clients.jedis.JedisPoolConfig; -import redis.clients.jedis.Protocol; - -@Getter -public class RedisPoolFactory { - - @JsonProperty - private String host = "localhost"; - - @JsonProperty - private int port = Protocol.DEFAULT_PORT; - - @JsonProperty - private String username; - - @JsonProperty - private String password; - - @JsonProperty - private int timeout = Protocol.DEFAULT_TIMEOUT; - - @JsonProperty - private int database = Protocol.DEFAULT_DATABASE; - - @JsonProperty - private int maxTotal = 500; - - public JedisPool build() { - JedisPoolConfig poolConfig = new JedisPoolConfig(); - poolConfig.setMaxTotal(maxTotal); - - JedisClientConfig clientConfig = DefaultJedisClientConfig.builder() - .user(username) - .password(password) - .timeoutMillis(timeout) - .database(database) - .build(); - - return new JedisPool(poolConfig, new HostAndPort(host, port), clientConfig); - } - -} 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 16bb496b..a84449a0 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 @@ -3,25 +3,22 @@ package com.commafeed.backend.dao; import java.util.List; import java.util.Objects; -import org.hibernate.SessionFactory; - 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; -import jakarta.inject.Inject; import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; @Singleton public class FeedCategoryDAO extends GenericDAO { private static final QFeedCategory CATEGORY = QFeedCategory.feedCategory; - @Inject - public FeedCategoryDAO(SessionFactory sessionFactory) { - super(sessionFactory); + public FeedCategoryDAO(EntityManager entityManager) { + super(entityManager, FeedCategory.class); } public List findAll(User user) { 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 f34a923e..ac1349b4 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 @@ -4,7 +4,6 @@ import java.time.Instant; import java.util.List; import org.apache.commons.lang3.StringUtils; -import org.hibernate.SessionFactory; import com.commafeed.backend.model.Feed; import com.commafeed.backend.model.QFeed; @@ -12,8 +11,8 @@ import com.commafeed.backend.model.QFeedSubscription; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQuery; -import jakarta.inject.Inject; import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; @Singleton public class FeedDAO extends GenericDAO { @@ -21,9 +20,12 @@ public class FeedDAO extends GenericDAO { private static final QFeed FEED = QFeed.feed; private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription; - @Inject - public FeedDAO(SessionFactory sessionFactory) { - super(sessionFactory); + 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) { 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 8d65b449..7e4f180e 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 @@ -2,16 +2,12 @@ package com.commafeed.backend.dao; import java.util.List; -import org.hibernate.SessionFactory; - import com.commafeed.backend.model.FeedEntryContent; import com.commafeed.backend.model.QFeedEntry; import com.commafeed.backend.model.QFeedEntryContent; -import com.querydsl.jpa.JPAExpressions; -import com.querydsl.jpa.JPQLSubQuery; -import jakarta.inject.Inject; import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; @Singleton public class FeedEntryContentDAO extends GenericDAO { @@ -19,9 +15,8 @@ public class FeedEntryContentDAO extends GenericDAO { private static final QFeedEntryContent CONTENT = QFeedEntryContent.feedEntryContent; private static final QFeedEntry ENTRY = QFeedEntry.feedEntry; - @Inject - public FeedEntryContentDAO(SessionFactory sessionFactory) { - super(sessionFactory); + public FeedEntryContentDAO(EntityManager entityManager) { + super(entityManager, FeedEntryContent.class); } public List findExisting(String contentHash, String titleHash) { @@ -29,9 +24,13 @@ public class FeedEntryContentDAO extends GenericDAO { } public long deleteWithoutEntries(int max) { - JPQLSubQuery subQuery = JPAExpressions.selectOne().from(ENTRY).where(ENTRY.content.id.eq(CONTENT.id)); - List ids = query().select(CONTENT.id).from(CONTENT).where(subQuery.notExists()).limit(max).fetch(); - + 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 2cf226ad..27d5de3f 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 @@ -3,16 +3,14 @@ package com.commafeed.backend.dao; import java.time.Instant; import java.util.List; -import org.hibernate.SessionFactory; - 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 jakarta.inject.Inject; import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; import lombok.AllArgsConstructor; import lombok.Getter; @@ -21,9 +19,8 @@ public class FeedEntryDAO extends GenericDAO { private static final QFeedEntry ENTRY = QFeedEntry.feedEntry; - @Inject - public FeedEntryDAO(SessionFactory sessionFactory) { - super(sessionFactory); + public FeedEntryDAO(EntityManager entityManager) { + super(entityManager, FeedEntry.class); } public FeedEntry findExisting(String guidHash, Feed feed) { 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 358fbaa2..dee56442 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 @@ -7,7 +7,6 @@ import java.util.Map; import java.util.stream.Collectors; import org.apache.commons.collections4.CollectionUtils; -import org.hibernate.SessionFactory; import com.commafeed.CommaFeedConfiguration; import com.commafeed.backend.feed.FeedEntryKeyword; @@ -28,8 +27,8 @@ import com.querydsl.core.BooleanBuilder; import com.querydsl.core.Tuple; import com.querydsl.jpa.impl.JPAQuery; -import jakarta.inject.Inject; import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; @Singleton public class FeedEntryStatusDAO extends GenericDAO { @@ -42,9 +41,8 @@ public class FeedEntryStatusDAO extends GenericDAO { private final FeedEntryTagDAO feedEntryTagDAO; private final CommaFeedConfiguration config; - @Inject - public FeedEntryStatusDAO(SessionFactory sessionFactory, FeedEntryTagDAO feedEntryTagDAO, CommaFeedConfiguration config) { - super(sessionFactory); + public FeedEntryStatusDAO(EntityManager entityManager, FeedEntryTagDAO feedEntryTagDAO, CommaFeedConfiguration config) { + super(entityManager, FeedEntryStatus.class); this.feedEntryTagDAO = feedEntryTagDAO; this.config = config; } @@ -60,8 +58,8 @@ public class FeedEntryStatusDAO extends GenericDAO { */ private FeedEntryStatus handleStatus(User user, FeedEntryStatus status, FeedSubscription sub, FeedEntry entry) { if (status == null) { - Instant unreadThreshold = config.getApplicationSettings().getUnreadThreshold(); - boolean read = unreadThreshold != null && entry.getPublished().isBefore(unreadThreshold); + 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); @@ -84,6 +82,7 @@ public class FeedEntryStatusDAO extends GenericDAO { 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(); } @@ -105,7 +104,7 @@ public class FeedEntryStatusDAO extends GenericDAO { query.limit(limit); } - setTimeout(query, config.getApplicationSettings().getQueryTimeout()); + setTimeout(query, config.database().queryTimeout()); List statuses = query.fetch(); statuses.forEach(s -> s.setMarkable(true)); @@ -179,7 +178,7 @@ public class FeedEntryStatusDAO extends GenericDAO { query.limit(limit); } - setTimeout(query, config.getApplicationSettings().getQueryTimeout()); + setTimeout(query, config.database().queryTimeout()); List statuses = new ArrayList<>(); List tuples = query.fetch(); @@ -217,9 +216,9 @@ public class FeedEntryStatusDAO extends GenericDAO { or.or(STATUS.read.isNull()); or.or(STATUS.read.isFalse()); - Instant unreadThreshold = config.getApplicationSettings().getUnreadThreshold(); - if (unreadThreshold != null) { - return or.and(ENTRY.published.goe(unreadThreshold)); + Instant statusesInstantThreshold = config.database().cleanup().statusesInstantThreshold(); + if (statusesInstantThreshold != null) { + return or.and(ENTRY.published.goe(statusesInstantThreshold)); } else { return or; } 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 5d87d729..bb00ad12 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 @@ -4,24 +4,21 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; -import org.hibernate.SessionFactory; - import com.commafeed.backend.model.FeedEntry; import com.commafeed.backend.model.FeedEntryTag; import com.commafeed.backend.model.QFeedEntryTag; import com.commafeed.backend.model.User; -import jakarta.inject.Inject; import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; @Singleton public class FeedEntryTagDAO extends GenericDAO { private static final QFeedEntryTag TAG = QFeedEntryTag.feedEntryTag; - @Inject - public FeedEntryTagDAO(SessionFactory sessionFactory) { - super(sessionFactory); + public FeedEntryTagDAO(EntityManager entityManager) { + super(entityManager, FeedEntryTag.class); } public List findByUser(User user) { 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 05b1cde7..990ac21c 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 @@ -5,12 +5,11 @@ import java.util.Set; import java.util.function.Consumer; import java.util.stream.Collectors; -import org.hibernate.SessionFactory; -import org.hibernate.engine.spi.SessionFactoryImplementor; +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.event.spi.PostInsertEventListener; import org.hibernate.persister.entity.EntityPersister; import com.commafeed.backend.model.AbstractModel; @@ -23,28 +22,28 @@ import com.commafeed.backend.model.User; import com.google.common.collect.Iterables; import com.querydsl.jpa.JPQLQuery; -import jakarta.inject.Inject; import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; @Singleton public class FeedSubscriptionDAO extends GenericDAO { private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription; - private final SessionFactory sessionFactory; + private final EntityManager entityManager; - @Inject - public FeedSubscriptionDAO(SessionFactory sessionFactory) { - super(sessionFactory); - this.sessionFactory = sessionFactory; + public FeedSubscriptionDAO(EntityManager entityManager) { + super(entityManager, FeedSubscription.class); + this.entityManager = entityManager; } public void onPostCommitInsert(Consumer consumer) { - sessionFactory.unwrap(SessionFactoryImplementor.class) + entityManager.unwrap(SharedSessionContractImplementor.class) + .getFactory() .getServiceRegistry() .getService(EventListenerRegistry.class) .getEventListenerGroup(EventType.POST_COMMIT_INSERT) - .appendListener(new PostInsertEventListener() { + .appendListener(new PostCommitInsertEventListener() { @Override public void onPostInsert(PostInsertEvent event) { if (event.getEntity() instanceof FeedSubscription s) { @@ -56,6 +55,11 @@ public class FeedSubscriptionDAO extends GenericDAO { public boolean requiresPostCommitHandling(EntityPersister persister) { return true; } + + @Override + public void onPostInsertCommitFailed(PostInsertEvent event) { + // do nothing + } }); } 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 b2a75bfb..1b5306c6 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,8 +1,9 @@ package com.commafeed.backend.dao; +import java.time.Duration; import java.util.Collection; -import org.hibernate.SessionFactory; +import org.hibernate.Session; import org.hibernate.jpa.SpecHints; import com.commafeed.backend.model.AbstractModel; @@ -12,45 +13,51 @@ import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import com.querydsl.jpa.impl.JPAUpdateClause; -import io.dropwizard.hibernate.AbstractDAO; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; -public abstract class GenericDAO extends AbstractDAO { +@RequiredArgsConstructor +public abstract class GenericDAO { - protected GenericDAO(SessionFactory sessionFactory) { - super(sessionFactory); - } + private final EntityManager entityManager; + private final Class entityClass; protected JPAQueryFactory query() { - return new JPAQueryFactory(currentSession()); + return new JPAQueryFactory(entityManager); } protected JPAUpdateClause updateQuery(EntityPath entityPath) { - return new JPAUpdateClause(currentSession(), entityPath); + return new JPAUpdateClause(entityManager, entityPath); } protected JPADeleteClause deleteQuery(EntityPath entityPath) { - return new JPADeleteClause(currentSession(), entityPath); + return new JPADeleteClause(entityManager, entityPath); } + @SuppressWarnings("deprecation") public void saveOrUpdate(T model) { - persist(model); + entityManager.unwrap(Session.class).saveOrUpdate(model); } public void saveOrUpdate(Collection models) { - models.forEach(this::persist); + models.forEach(this::saveOrUpdate); } - public void update(T model) { - currentSession().merge(model); + public void persist(T model) { + entityManager.persist(model); + } + + public T merge(T model) { + return entityManager.merge(model); } public T findById(Long id) { - return get(id); + return entityManager.find(entityClass, id); } public void delete(T object) { if (object != null) { - currentSession().remove(object); + entityManager.remove(object); } } @@ -59,9 +66,9 @@ public abstract class GenericDAO extends AbstractDAO return objects.size(); } - protected void setTimeout(JPAQuery query, int timeoutMs) { - if (timeoutMs > 0) { - query.setHint(SpecHints.HINT_SPEC_QUERY_TIMEOUT, timeoutMs); + 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 87be4676..5caa1ed2 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,68 +1,20 @@ package com.commafeed.backend.dao; -import org.hibernate.Session; -import org.hibernate.SessionFactory; -import org.hibernate.Transaction; -import org.hibernate.context.internal.ManagedSessionContext; - -import jakarta.inject.Inject; +import io.quarkus.narayana.jta.QuarkusTransaction; import jakarta.inject.Singleton; -import lombok.RequiredArgsConstructor; -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) @Singleton public class UnitOfWork { - private final SessionFactory sessionFactory; - - public void run(SessionRunner sessionRunner) { + public void run(SessionRunner runner) { call(() -> { - sessionRunner.runInSession(); + runner.runInSession(); return null; }); } - public T call(SessionRunnerReturningValue sessionRunner) { - T t = null; - - boolean sessionAlreadyBound = ManagedSessionContext.hasBind(sessionFactory); - try (Session session = sessionFactory.openSession()) { - if (!sessionAlreadyBound) { - ManagedSessionContext.bind(session); - } - - Transaction tx = session.beginTransaction(); - try { - t = sessionRunner.runInSession(); - commitTransaction(tx); - } catch (Exception e) { - rollbackTransaction(tx); - UnitOfWork.rethrow(e); - } - } finally { - if (!sessionAlreadyBound) { - ManagedSessionContext.unbind(sessionFactory); - } - } - - return t; - } - - private static void rollbackTransaction(Transaction tx) { - if (tx != null && tx.isActive()) { - tx.rollback(); - } - } - - private static void commitTransaction(Transaction tx) { - if (tx != null && tx.isActive()) { - tx.commit(); - } - } - - @SuppressWarnings("unchecked") - private static void rethrow(Exception e) throws E { - throw (E) e; + public T call(SessionRunnerReturningValue runner) { + return QuarkusTransaction.joiningExisting().call(runner::runInSession); } @FunctionalInterface 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 ae7d87f4..ea7e475c 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,21 +1,18 @@ package com.commafeed.backend.dao; -import org.hibernate.SessionFactory; - import com.commafeed.backend.model.QUser; import com.commafeed.backend.model.User; -import jakarta.inject.Inject; import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; @Singleton public class UserDAO extends GenericDAO { private static final QUser USER = QUser.user; - @Inject - public UserDAO(SessionFactory sessionFactory) { - super(sessionFactory); + public UserDAO(EntityManager entityManager) { + super(entityManager, User.class); } public User findByName(String name) { 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 37c23e92..81d06301 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 @@ -4,24 +4,21 @@ import java.util.List; import java.util.Set; import java.util.stream.Collectors; -import org.hibernate.SessionFactory; - 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; -import jakarta.inject.Inject; import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; @Singleton public class UserRoleDAO extends GenericDAO { private static final QUserRole ROLE = QUserRole.userRole; - @Inject - public UserRoleDAO(SessionFactory sessionFactory) { - super(sessionFactory); + public UserRoleDAO(EntityManager entityManager) { + super(entityManager, UserRole.class); } public List findAll() { 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 a95450ad..89048624 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,19 @@ package com.commafeed.backend.dao; -import org.hibernate.SessionFactory; - import com.commafeed.backend.model.QUserSettings; import com.commafeed.backend.model.User; import com.commafeed.backend.model.UserSettings; -import jakarta.inject.Inject; import jakarta.inject.Singleton; +import jakarta.persistence.EntityManager; @Singleton public class UserSettingsDAO extends GenericDAO { private static final QUserSettings SETTINGS = QUserSettings.userSettings; - @Inject - public UserSettingsDAO(SessionFactory sessionFactory) { - super(sessionFactory); + public UserSettingsDAO(EntityManager entityManager) { + super(entityManager, UserSettings.class); } public UserSettings findByUser(User user) { 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 06e8054b..c33fc5f0 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 @@ -12,8 +12,6 @@ import lombok.extern.slf4j.Slf4j; @Slf4j public abstract class AbstractFaviconFetcher { - protected static final int TIMEOUT = 4000; - 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; 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 7b814fef..1dc94a1c 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 @@ -10,7 +10,7 @@ import com.commafeed.backend.HttpGetter.HttpResult; import com.commafeed.backend.feed.FeedUtils; import com.commafeed.backend.model.Feed; -import jakarta.inject.Inject; +import jakarta.annotation.Priority; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -20,8 +20,9 @@ import lombok.extern.slf4j.Slf4j; * */ @Slf4j -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton +@Priority(Integer.MIN_VALUE) public class DefaultFaviconFetcher extends AbstractFaviconFetcher { private final HttpGetter getter; @@ -68,7 +69,7 @@ public class DefaultFaviconFetcher extends AbstractFaviconFetcher { try { url = FeedUtils.removeTrailingSlash(url) + "/favicon.ico"; log.debug("getting root icon at {}", url); - HttpResult result = getter.getBinary(url, TIMEOUT); + HttpResult result = getter.getBinary(url); bytes = result.getContent(); contentType = result.getContentType(); } catch (Exception e) { @@ -86,7 +87,7 @@ public class DefaultFaviconFetcher extends AbstractFaviconFetcher { Document doc; try { - HttpResult result = getter.getBinary(url, TIMEOUT); + HttpResult result = getter.getBinary(url); doc = Jsoup.parse(new String(result.getContent()), url); } catch (Exception e) { log.debug("Failed to retrieve page to find icon"); @@ -112,7 +113,7 @@ public class DefaultFaviconFetcher extends AbstractFaviconFetcher { byte[] bytes; String contentType; try { - HttpResult result = getter.getBinary(href, TIMEOUT); + HttpResult result = getter.getBinary(href); bytes = result.getContent(); contentType = result.getContentType(); } catch (Exception e) { 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 fd9722f0..fad9d1ef 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 @@ -11,13 +11,12 @@ import com.commafeed.backend.HttpGetter; import com.commafeed.backend.HttpGetter.HttpResult; import com.commafeed.backend.model.Feed; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton public class FacebookFaviconFetcher extends AbstractFaviconFetcher { @@ -44,7 +43,7 @@ public class FacebookFaviconFetcher extends AbstractFaviconFetcher { try { log.debug("Getting Facebook user's icon, {}", url); - HttpResult iconResult = getter.getBinary(iconUrl, TIMEOUT); + HttpResult iconResult = getter.getBinary(iconUrl); bytes = iconResult.getContent(); contentType = iconResult.getContentType(); } catch (Exception e) { 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 def1dda5..02613278 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 @@ -22,13 +22,12 @@ import com.google.api.services.youtube.model.ChannelListResponse; import com.google.api.services.youtube.model.PlaylistListResponse; import com.google.api.services.youtube.model.Thumbnail; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton public class YoutubeFaviconFetcher extends AbstractFaviconFetcher { @@ -43,8 +42,8 @@ public class YoutubeFaviconFetcher extends AbstractFaviconFetcher { return null; } - String googleAuthKey = config.getApplicationSettings().getGoogleAuthKey(); - if (googleAuthKey == null) { + Optional googleAuthKey = config.googleAuthKey(); + if (googleAuthKey.isEmpty()) { log.debug("no google auth key configured"); return null; } @@ -63,13 +62,13 @@ public class YoutubeFaviconFetcher extends AbstractFaviconFetcher { ChannelListResponse response = null; if (userId.isPresent()) { log.debug("contacting youtube api for user {}", userId.get().getValue()); - response = fetchForUser(youtube, googleAuthKey, userId.get().getValue()); + response = fetchForUser(youtube, googleAuthKey.get(), userId.get().getValue()); } else if (channelId.isPresent()) { log.debug("contacting youtube api for channel {}", channelId.get().getValue()); - response = fetchForChannel(youtube, googleAuthKey, channelId.get().getValue()); + response = fetchForChannel(youtube, googleAuthKey.get(), channelId.get().getValue()); } else if (playlistId.isPresent()) { log.debug("contacting youtube api for playlist {}", playlistId.get().getValue()); - response = fetchForPlaylist(youtube, googleAuthKey, playlistId.get().getValue()); + response = fetchForPlaylist(youtube, googleAuthKey.get(), playlistId.get().getValue()); } if (response == null || response.isEmpty() || CollectionUtils.isEmpty(response.getItems())) { @@ -81,7 +80,7 @@ public class YoutubeFaviconFetcher extends AbstractFaviconFetcher { Thumbnail thumbnail = channel.getSnippet().getThumbnails().getDefault(); log.debug("fetching favicon"); - HttpResult iconResult = getter.getBinary(thumbnail.getUrl(), TIMEOUT); + HttpResult iconResult = getter.getBinary(thumbnail.getUrl()); bytes = iconResult.getContent(); contentType = iconResult.getContentType(); } catch (Exception e) { 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 18f9f2a0..8accfdf5 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 @@ -2,7 +2,7 @@ package com.commafeed.backend.feed; import java.io.IOException; import java.time.Instant; -import java.util.Set; +import java.util.List; import org.apache.commons.codec.binary.StringUtils; @@ -15,30 +15,32 @@ import com.commafeed.backend.feed.parser.FeedParserResult; import com.commafeed.backend.urlprovider.FeedURLProvider; import com.rometools.rome.io.FeedException; -import jakarta.inject.Inject; +import io.quarkus.arc.All; import jakarta.inject.Singleton; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** * Fetches a feed then parses it */ @Slf4j -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) @Singleton public class FeedFetcher { private final FeedParser parser; private final HttpGetter getter; - private final Set urlProviders; + 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 FeedException, IOException, NotModifiedException { log.debug("Fetching feed {}", feedUrl); - int timeout = 20000; - - HttpResult result = getter.getBinary(feedUrl, lastModified, eTag, timeout); + HttpResult result = getter.getBinary(feedUrl, lastModified, eTag); byte[] content = result.getContent(); FeedParserResult parserResult; @@ -50,7 +52,7 @@ public class FeedFetcher { if (org.apache.commons.lang3.StringUtils.isNotBlank(extractedUrl)) { feedUrl = extractedUrl; - result = getter.getBinary(extractedUrl, lastModified, eTag, timeout); + result = getter.getBinary(extractedUrl, lastModified, eTag); content = result.getContent(); parserResult = parser.parse(result.getUrlAfterRedirect(), content); } else { @@ -83,11 +85,10 @@ public class FeedFetcher { etagHeaderValueChanged ? result.getETag() : null); } - return new FeedFetcherResult(parserResult, result.getUrlAfterRedirect(), result.getLastModifiedSince(), result.getETag(), hash, - result.getDuration()); + return new FeedFetcherResult(parserResult, result.getUrlAfterRedirect(), result.getLastModifiedSince(), result.getETag(), hash); } - private static String extractFeedUrl(Set urlProviders, String url, String urlContent) { + private static String extractFeedUrl(List urlProviders, String url, String urlContent) { for (FeedURLProvider urlProvider : urlProviders) { String feedUrl = urlProvider.get(url, urlContent); if (feedUrl != null) { @@ -99,7 +100,7 @@ public class FeedFetcher { } public record FeedFetcherResult(FeedParserResult feed, String urlAfterRedirect, String lastModifiedHeader, String lastETagHeader, - String contentHash, long fetchDuration) { + String contentHash) { } } 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 9f39cbf0..bd1ece18 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,6 +1,5 @@ package com.commafeed.backend.feed; -import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.concurrent.BlockingDeque; @@ -21,14 +20,12 @@ import com.commafeed.backend.dao.UnitOfWork; import com.commafeed.backend.model.AbstractModel; import com.commafeed.backend.model.Feed; -import io.dropwizard.lifecycle.Managed; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.extern.slf4j.Slf4j; @Slf4j @Singleton -public class FeedRefreshEngine implements Managed { +public class FeedRefreshEngine { private final UnitOfWork unitOfWork; private final FeedDAO feedDAO; @@ -45,7 +42,6 @@ public class FeedRefreshEngine implements Managed { private final ThreadPoolExecutor workerExecutor; private final ThreadPoolExecutor databaseUpdaterExecutor; - @Inject public FeedRefreshEngine(UnitOfWork unitOfWork, FeedDAO feedDAO, FeedRefreshWorker worker, FeedRefreshUpdater updater, CommaFeedConfiguration config, MetricRegistry metrics) { this.unitOfWork = unitOfWork; @@ -60,15 +56,14 @@ public class FeedRefreshEngine implements Managed { this.feedProcessingLoopExecutor = Executors.newSingleThreadExecutor(); this.refillLoopExecutor = Executors.newSingleThreadExecutor(); this.refillExecutor = newDiscardingSingleThreadExecutorService(); - this.workerExecutor = newBlockingExecutorService(config.getApplicationSettings().getBackgroundThreads()); - this.databaseUpdaterExecutor = newBlockingExecutorService(config.getApplicationSettings().getDatabaseUpdateThreads()); + 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); } - @Override public void start() { startFeedProcessingLoop(); startRefillLoop(); @@ -165,22 +160,20 @@ public class FeedRefreshEngine implements Managed { private List getNextUpdatableFeeds(int max) { return unitOfWork.call(() -> { - Instant lastLoginThreshold = Boolean.TRUE.equals(config.getApplicationSettings().getHeavyLoad()) - ? Instant.now().minus(Duration.ofDays(30)) - : null; + 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(Duration.ofMinutes(config.getApplicationSettings().getRefreshIntervalMinutes())); + 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.getApplicationSettings().getBackgroundThreads()); + return Math.min(100, 3 * config.feedRefresh().httpThreads()); } - @Override public void stop() { this.feedProcessingLoopExecutor.shutdownNow(); this.refillLoopExecutor.shutdownNow(); 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 d4097332..48051719 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 @@ -6,24 +6,22 @@ import java.time.temporal.ChronoUnit; import com.commafeed.CommaFeedConfiguration; -import jakarta.inject.Inject; import jakarta.inject.Singleton; @Singleton public class FeedRefreshIntervalCalculator { - private final boolean heavyLoad; - private final int refreshIntervalMinutes; + private final Duration refreshInterval; + private final boolean empiricalInterval; - @Inject public FeedRefreshIntervalCalculator(CommaFeedConfiguration config) { - this.heavyLoad = config.getApplicationSettings().getHeavyLoad(); - this.refreshIntervalMinutes = config.getApplicationSettings().getRefreshIntervalMinutes(); + this.refreshInterval = config.feedRefresh().interval(); + this.empiricalInterval = config.feedRefresh().intervalEmpirical(); } public Instant onFetchSuccess(Instant publishedDate, Long averageEntryInterval) { Instant defaultRefreshInterval = getDefaultRefreshInterval(); - return heavyLoad ? computeRefreshIntervalForHeavyLoad(publishedDate, averageEntryInterval, defaultRefreshInterval) + return empiricalInterval ? computeEmpiricalRefreshInterval(publishedDate, averageEntryInterval, defaultRefreshInterval) : defaultRefreshInterval; } @@ -33,7 +31,7 @@ public class FeedRefreshIntervalCalculator { public Instant onFetchError(int errorCount) { int retriesBeforeDisable = 3; - if (errorCount < retriesBeforeDisable || !heavyLoad) { + if (errorCount < retriesBeforeDisable || !empiricalInterval) { return getDefaultRefreshInterval(); } @@ -42,10 +40,10 @@ public class FeedRefreshIntervalCalculator { } private Instant getDefaultRefreshInterval() { - return Instant.now().plus(Duration.ofMinutes(refreshIntervalMinutes)); + return Instant.now().plus(refreshInterval); } - private Instant computeRefreshIntervalForHeavyLoad(Instant publishedDate, Long averageEntryInterval, Instant defaultRefreshInterval) { + private Instant computeEmpiricalRefreshInterval(Instant publishedDate, Long averageEntryInterval, Instant defaultRefreshInterval) { Instant now = Instant.now(); if (publishedDate == null) { 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 ea891b03..6bf9d009 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,6 +1,5 @@ package com.commafeed.backend.feed; -import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; @@ -16,7 +15,6 @@ 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.cache.CacheService; import com.commafeed.backend.dao.FeedSubscriptionDAO; import com.commafeed.backend.dao.UnitOfWork; import com.commafeed.backend.feed.parser.FeedParserResult.Content; @@ -25,14 +23,12 @@ 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.model.User; 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 jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -48,30 +44,23 @@ public class FeedRefreshUpdater { private final FeedService feedService; private final FeedEntryService feedEntryService; private final FeedSubscriptionDAO feedSubscriptionDAO; - private final CacheService cache; private final WebSocketSessions webSocketSessions; private final Striped locks; - private final Meter entryCacheMiss; - private final Meter entryCacheHit; private final Meter feedUpdated; private final Meter entryInserted; - @Inject public FeedRefreshUpdater(UnitOfWork unitOfWork, FeedService feedService, FeedEntryService feedEntryService, MetricRegistry metrics, - FeedSubscriptionDAO feedSubscriptionDAO, CacheService cache, WebSocketSessions webSocketSessions) { + FeedSubscriptionDAO feedSubscriptionDAO, WebSocketSessions webSocketSessions) { this.unitOfWork = unitOfWork; this.feedService = feedService; this.feedEntryService = feedEntryService; this.feedSubscriptionDAO = feedSubscriptionDAO; - this.cache = cache; this.webSocketSessions = webSocketSessions; locks = Striped.lazyWeakLock(100000); - entryCacheMiss = metrics.meter(MetricRegistry.name(getClass(), "entryCacheMiss")); - entryCacheHit = metrics.meter(MetricRegistry.name(getClass(), "entryCacheHit")); feedUpdated = metrics.meter(MetricRegistry.name(getClass(), "feedUpdated")); entryInserted = metrics.meter(MetricRegistry.name(getClass(), "entryInserted")); } @@ -141,39 +130,21 @@ public class FeedRefreshUpdater { Map unreadCountBySubscription = new HashMap<>(); if (!entries.isEmpty()) { - Set lastEntries = cache.getLastEntries(feed); - List currentEntries = new ArrayList<>(); - List subscriptions = null; for (Entry entry : entries) { - String cacheKey = cache.buildUniqueEntryKey(entry); - if (!lastEntries.contains(cacheKey)) { - log.debug("cache miss for {}", entry.url()); - 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)); - - entryCacheMiss.mark(); - } else { - log.debug("cache hit for {}", entry.url()); - entryCacheHit.mark(); + if (subscriptions == null) { + subscriptions = unitOfWork.call(() -> feedSubscriptionDAO.findByFeed(feed)); } - - currentEntries.add(cacheKey); + AddEntryResult addEntryResult = addEntry(feed, entry, subscriptions); + processed &= addEntryResult.processed; + inserted += addEntryResult.inserted ? 1 : 0; + addEntryResult.subscriptionsForWhichEntryIsUnread.forEach(sub -> unreadCountBySubscription.merge(sub, 1L, Long::sum)); } - cache.setLastEntries(feed, currentEntries); - if (subscriptions == null) { + if (inserted == 0) { feed.setMessage("No new entries found"); } else if (inserted > 0) { - log.debug("inserted {} entries for feed {}", inserted, feed.getId()); - List users = subscriptions.stream().map(FeedSubscription::getUser).toList(); - cache.invalidateUnreadCount(subscriptions.toArray(new FeedSubscription[0])); - cache.invalidateUserRootCategory(users.toArray(new User[0])); + feed.setMessage("Found %s new entries".formatted(inserted)); } } @@ -186,7 +157,7 @@ public class FeedRefreshUpdater { feedUpdated.mark(); } - unitOfWork.run(() -> feedService.save(feed)); + unitOfWork.run(() -> feedService.update(feed)); notifyOverWebsocket(unreadCountBySubscription); 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 fa855f2a..11421313 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 @@ -16,7 +16,6 @@ import com.commafeed.backend.feed.FeedFetcher.FeedFetcherResult; import com.commafeed.backend.feed.parser.FeedParserResult.Entry; import com.commafeed.backend.model.Feed; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.extern.slf4j.Slf4j; @@ -32,7 +31,6 @@ public class FeedRefreshWorker { private final CommaFeedConfiguration config; private final Meter feedFetched; - @Inject public FeedRefreshWorker(FeedRefreshIntervalCalculator refreshIntervalCalculator, FeedFetcher fetcher, CommaFeedConfiguration config, MetricRegistry metrics) { this.refreshIntervalCalculator = refreshIntervalCalculator; @@ -51,14 +49,14 @@ public class FeedRefreshWorker { List entries = result.feed().entries(); - Integer maxFeedCapacity = config.getApplicationSettings().getMaxFeedCapacity(); + int maxFeedCapacity = config.database().cleanup().maxFeedCapacity(); if (maxFeedCapacity > 0) { entries = entries.stream().limit(maxFeedCapacity).toList(); } - Integer maxEntriesAgeDays = config.getApplicationSettings().getMaxEntriesAgeDays(); - if (maxEntriesAgeDays > 0) { - Instant threshold = Instant.now().minus(Duration.ofDays(maxEntriesAgeDays)); + Duration maxEntriesAgeDays = config.database().cleanup().entriesMaxAge(); + if (!maxEntriesAgeDays.isZero()) { + Instant threshold = Instant.now().minus(maxEntriesAgeDays); entries = entries.stream().filter(entry -> entry.published().isAfter(threshold)).toList(); } 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 2f62c530..d25f7c13 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 @@ -38,14 +38,13 @@ import com.rometools.rome.feed.synd.SyndLinkImpl; import com.rometools.rome.io.FeedException; import com.rometools.rome.io.SyndFeedInput; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; /** * Parses raw xml into a FeedParserResult object */ -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton public class FeedParser { 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 5dc3329d..f451ac93 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 @@ -15,11 +15,10 @@ import com.rometools.opml.feed.opml.Attribute; import com.rometools.opml.feed.opml.Opml; import com.rometools.opml.feed.opml.Outline; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton public class OPMLExporter { @@ -28,7 +27,7 @@ public class OPMLExporter { public Opml export(User user) { Opml opml = new Opml(); - opml.setFeedType("opml_1.1"); + opml.setFeedType("opml_1.0"); opml.setTitle(String.format("%s subscriptions in CommaFeed", user.getName())); opml.setCreated(new Date()); 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 a4a00480..4465025c 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 @@ -6,7 +6,6 @@ import java.util.List; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; -import com.commafeed.backend.cache.CacheService; import com.commafeed.backend.dao.FeedCategoryDAO; import com.commafeed.backend.feed.FeedUtils; import com.commafeed.backend.model.FeedCategory; @@ -17,19 +16,17 @@ import com.rometools.opml.feed.opml.Outline; import com.rometools.rome.io.FeedException; import com.rometools.rome.io.WireFeedInput; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton public class OPMLImporter { private final FeedCategoryDAO feedCategoryDAO; private final FeedSubscriptionService feedSubscriptionService; - private final CacheService cache; public void importOpml(User user, String xml) throws IllegalArgumentException, FeedException { xml = xml.substring(xml.indexOf('<')); @@ -80,6 +77,5 @@ public class OPMLImporter { log.error("error while importing {}: {}", outline.getXmlUrl(), e.getMessage()); } } - cache.invalidateUserRootCategory(user); } } 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 4bfc44af..480a1ba7 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 @@ -4,10 +4,13 @@ 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() { 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 3342a5c9..8770667a 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 @@ -9,10 +9,13 @@ 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() { 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 c2154565..404364b2 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 @@ -6,10 +6,13 @@ 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 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 d30f82dc..abde98ed 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 @@ -8,10 +8,13 @@ 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 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 4c101230..6c7e11c7 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 @@ -10,6 +10,9 @@ 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/"; 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 5886f20a..a776c452 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 @@ -20,13 +20,13 @@ 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 jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Slf4j @Singleton public class FeedEntryContentCleaningService { @@ -150,7 +150,7 @@ public class FeedEntryContentCleaningService { } private CSSOMParser buildCssParser() { - CSSOMParser parser = new CSSOMParser(); + CSSOMParser parser = new CSSOMParser(new SACParserCSS21()); parser.setErrorHandler(new ErrorHandler() { @Override 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 1ad16ab5..879f42f3 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 @@ -12,11 +12,10 @@ import com.commafeed.backend.feed.parser.FeedParserResult.Enclosure; import com.commafeed.backend.feed.parser.FeedParserResult.Media; import com.commafeed.backend.model.FeedEntryContent; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton public class FeedEntryContentService { 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 2ca965a2..dd8857d6 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 @@ -23,19 +23,20 @@ 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 jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@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 @@ -97,8 +98,9 @@ public class FeedEntryFilteringService { Future future = executor.submit(callable); Object result; try { - result = future.get(500, TimeUnit.MILLISECONDS); + 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); 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 d75e1915..505245a6 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 @@ -4,7 +4,6 @@ import java.time.Instant; import java.util.List; import com.commafeed.backend.Digests; -import com.commafeed.backend.cache.CacheService; import com.commafeed.backend.dao.FeedEntryDAO; import com.commafeed.backend.dao.FeedEntryStatusDAO; import com.commafeed.backend.dao.FeedSubscriptionDAO; @@ -18,13 +17,12 @@ import com.commafeed.backend.model.FeedSubscription; import com.commafeed.backend.model.User; import com.commafeed.backend.service.FeedEntryFilteringService.FeedEntryFilterException; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton public class FeedEntryService { @@ -33,7 +31,6 @@ public class FeedEntryService { private final FeedEntryStatusDAO feedEntryStatusDAO; private final FeedEntryContentService feedEntryContentService; private final FeedEntryFilteringService feedEntryFilteringService; - private final CacheService cache; public FeedEntry find(Feed feed, Entry entry) { String guidHash = Digests.sha1Hex(entry.guid()); @@ -86,8 +83,6 @@ public class FeedEntryService { if (status.isMarkable()) { status.setRead(read); feedEntryStatusDAO.saveOrUpdate(status); - cache.invalidateUnreadCount(sub); - cache.invalidateUserRootCategory(user); } } @@ -113,8 +108,6 @@ public class FeedEntryService { List statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, true, keywords, null, -1, -1, null, false, null, null, null); markList(statuses, olderThan, insertedBefore); - cache.invalidateUnreadCount(subscriptions.toArray(new FeedSubscription[0])); - cache.invalidateUserRootCategory(user); } public void markStarredEntries(User user, Instant olderThan, Instant insertedBefore) { 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 70f451c9..540956ab 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 @@ -10,11 +10,10 @@ import com.commafeed.backend.model.FeedEntry; import com.commafeed.backend.model.FeedEntryTag; import com.commafeed.backend.model.User; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton public class FeedEntryTagService { 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 69a5ecf2..302c692c 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 @@ -2,8 +2,8 @@ package com.commafeed.backend.service; import java.io.IOException; import java.time.Instant; +import java.util.List; import java.util.Objects; -import java.util.Set; import com.commafeed.backend.Digests; import com.commafeed.backend.dao.FeedDAO; @@ -14,19 +14,18 @@ import com.commafeed.backend.model.Feed; import com.commafeed.backend.model.Models; import com.google.common.io.Resources; -import jakarta.inject.Inject; +import io.quarkus.arc.All; import jakarta.inject.Singleton; @Singleton public class FeedService { private final FeedDAO feedDAO; - private final Set faviconFetchers; + private final List faviconFetchers; private final Favicon defaultFavicon; - @Inject - public FeedService(FeedDAO feedDAO, Set faviconFetchers) { + public FeedService(FeedDAO feedDAO, @All List faviconFetchers) { this.feedDAO = feedDAO; this.faviconFetchers = faviconFetchers; @@ -48,18 +47,18 @@ public class FeedService { feed.setNormalizedUrl(normalizedUrl); feed.setNormalizedUrlHash(normalizedUrlHash); feed.setDisabledUntil(Models.MINIMUM_INSTANT); - feedDAO.saveOrUpdate(feed); + feedDAO.persist(feed); } return feed; } - public void save(Feed 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.saveOrUpdate(feed); + feedDAO.merge(feed); } public Favicon fetchFavicon(Feed feed) { 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 444b9f34..a3d69ba5 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 @@ -5,10 +5,7 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; -import org.apache.commons.lang3.StringUtils; - import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.cache.CacheService; import com.commafeed.backend.dao.FeedDAO; import com.commafeed.backend.dao.FeedEntryStatusDAO; import com.commafeed.backend.dao.FeedSubscriptionDAO; @@ -17,11 +14,9 @@ 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.Models; import com.commafeed.backend.model.User; import com.commafeed.frontend.model.UnreadCount; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.extern.slf4j.Slf4j; @@ -34,18 +29,15 @@ public class FeedSubscriptionService { private final FeedSubscriptionDAO feedSubscriptionDAO; private final FeedService feedService; private final FeedRefreshEngine feedRefreshEngine; - private final CacheService cache; private final CommaFeedConfiguration config; - @Inject public FeedSubscriptionService(FeedDAO feedDAO, FeedEntryStatusDAO feedEntryStatusDAO, FeedSubscriptionDAO feedSubscriptionDAO, - FeedService feedService, FeedRefreshEngine feedRefreshEngine, CacheService cache, CommaFeedConfiguration config) { + FeedService feedService, FeedRefreshEngine feedRefreshEngine, CommaFeedConfiguration config) { this.feedDAO = feedDAO; this.feedEntryStatusDAO = feedEntryStatusDAO; this.feedSubscriptionDAO = feedSubscriptionDAO; this.feedService = feedService; this.feedRefreshEngine = feedRefreshEngine; - this.cache = cache; this.config = config; // automatically refresh feeds after they are subscribed to @@ -62,16 +54,7 @@ public class FeedSubscriptionService { } public long subscribe(User user, String url, String title, FeedCategory category, int position) { - - final String pubUrl = config.getApplicationSettings().getPublicUrl(); - if (StringUtils.isBlank(pubUrl)) { - throw new FeedSubscriptionException("Public URL of this CommaFeed instance is not set"); - } - if (url.startsWith(pubUrl)) { - throw new FeedSubscriptionException("Could not subscribe to a feed from this CommaFeed instance"); - } - - Integer maxFeedsPerUser = config.getApplicationSettings().getMaxFeedsPerUser(); + 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); @@ -97,7 +80,6 @@ public class FeedSubscriptionService { sub.setTitle(FeedUtils.truncate(title, 128)); feedSubscriptionDAO.saveOrUpdate(sub); - cache.invalidateUserRootCategory(user); return sub.getId(); } @@ -105,7 +87,6 @@ public class FeedSubscriptionService { FeedSubscription sub = feedSubscriptionDAO.findById(user, subId); if (sub != null) { feedSubscriptionDAO.delete(sub); - cache.invalidateUserRootCategory(user); return true; } else { return false; @@ -132,17 +113,9 @@ public class FeedSubscriptionService { } public Map getUnreadCount(User user) { - return feedSubscriptionDAO.findAll(user).stream().collect(Collectors.toMap(FeedSubscription::getId, this::getUnreadCount)); - } - - private UnreadCount getUnreadCount(FeedSubscription sub) { - UnreadCount count = cache.getUnreadCount(sub); - if (count == null) { - log.debug("unread count cache miss for {}", Models.getId(sub)); - count = feedEntryStatusDAO.getUnreadCount(sub); - cache.setUnreadCount(sub, count); - } - return count; + return feedSubscriptionDAO.findAll(user) + .stream() + .collect(Collectors.toMap(FeedSubscription::getId, feedEntryStatusDAO::getUnreadCount)); } @SuppressWarnings("serial") 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 e50556f0..cef02d1f 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,63 +1,20 @@ package com.commafeed.backend.service; -import java.util.Optional; -import java.util.Properties; - -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.CommaFeedConfiguration.ApplicationSettings; import com.commafeed.backend.model.User; -import jakarta.inject.Inject; +import io.quarkus.mailer.Mail; +import io.quarkus.mailer.Mailer; import jakarta.inject.Singleton; -import jakarta.mail.Authenticator; -import jakarta.mail.Message; -import jakarta.mail.PasswordAuthentication; -import jakarta.mail.Session; -import jakarta.mail.Transport; -import jakarta.mail.internet.InternetAddress; -import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; -/** - * Mailing service - * - */ -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton public class MailService { - private final CommaFeedConfiguration config; - - public void sendMail(User user, String subject, String content) throws Exception { - - ApplicationSettings settings = config.getApplicationSettings(); - - final String username = settings.getSmtpUserName(); - final String password = settings.getSmtpPassword(); - final String fromAddress = Optional.ofNullable(settings.getSmtpFromAddress()).orElse(settings.getSmtpUserName()); - - String dest = user.getEmail(); - - Properties props = new Properties(); - props.put("mail.smtp.auth", "true"); - props.put("mail.smtp.starttls.enable", String.valueOf(settings.isSmtpTls())); - props.put("mail.smtp.host", settings.getSmtpHost()); - props.put("mail.smtp.port", String.valueOf(settings.getSmtpPort())); - - Session session = Session.getInstance(props, new Authenticator() { - @Override - protected PasswordAuthentication getPasswordAuthentication() { - return new PasswordAuthentication(username, password); - } - }); - - Message message = new MimeMessage(session); - message.setFrom(new InternetAddress(fromAddress, "CommaFeed")); - message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(dest)); - message.setSubject("CommaFeed - " + subject); - message.setContent(content, "text/html; charset=utf-8"); - - Transport.send(message); + 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 26aa70b9..a6f7d5c6 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 @@ -12,7 +12,6 @@ import javax.crypto.spec.PBEKeySpec; import org.apache.commons.lang3.StringUtils; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -20,7 +19,7 @@ import lombok.extern.slf4j.Slf4j; // taken from http://www.javacodegeeks.com/2012/05/secure-password-storage-donts-dos-and.html @SuppressWarnings("serial") @Slf4j -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton public class PasswordEncryptionService implements Serializable { 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 76a0df1c..b56d4c47 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 @@ -24,11 +24,10 @@ import com.commafeed.backend.model.UserRole.Role; import com.commafeed.backend.service.internal.PostLoginActivities; import com.google.common.base.Preconditions; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton public class UserService { @@ -117,8 +116,7 @@ public class UserService { public User register(String name, String password, String email, Collection roles, boolean forceRegistration) { if (!forceRegistration) { - Preconditions.checkState(config.getApplicationSettings().getAllowRegistrations(), - "Registrations are closed on this CommaFeed instance"); + Preconditions.checkState(config.users().allowRegistrations(), "Registrations are closed on this CommaFeed instance"); } Preconditions.checkArgument(userDAO.findByName(name) == null, "Name already taken"); 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 a8f54d4e..310f6926 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 @@ -12,9 +12,9 @@ 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 jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.extern.slf4j.Slf4j; @@ -35,7 +35,6 @@ public class DatabaseCleaningService { private final FeedEntryStatusDAO feedEntryStatusDAO; private final Meter entriesDeletedMeter; - @Inject public DatabaseCleaningService(CommaFeedConfiguration config, UnitOfWork unitOfWork, FeedDAO feedDAO, FeedEntryDAO feedEntryDAO, FeedEntryContentDAO feedEntryContentDAO, FeedEntryStatusDAO feedEntryStatusDAO, MetricRegistry metrics) { this.unitOfWork = unitOfWork; @@ -43,7 +42,7 @@ public class DatabaseCleaningService { this.feedEntryDAO = feedEntryDAO; this.feedEntryContentDAO = feedEntryContentDAO; this.feedEntryStatusDAO = feedEntryStatusDAO; - this.batchSize = config.getApplicationSettings().getDatabaseCleanupBatchSize(); + this.batchSize = config.database().cleanup().batchSize(); this.entriesDeletedMeter = metrics.meter(MetricRegistry.name(getClass(), "entriesDeleted")); } @@ -63,7 +62,7 @@ public class DatabaseCleaningService { log.info("removed {} entries for feeds without subscriptions", entriesTotal); } while (entriesDeleted > 0); } - deleted = unitOfWork.call(() -> feedDAO.delete(feeds)); + deleted = unitOfWork.call(() -> feedDAO.delete(feedDAO.findByIds(feeds.stream().map(AbstractModel::getId).toList()))); total += deleted; log.info("removed {} feeds without subscriptions", total); } while (deleted != 0); 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 865118e6..a5bbe584 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,109 +1,41 @@ package com.commafeed.backend.service.db; -import java.util.HashMap; -import java.util.Map; - -import org.hibernate.Session; -import org.hibernate.SessionFactory; +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 io.dropwizard.lifecycle.Managed; -import jakarta.inject.Inject; import jakarta.inject.Singleton; -import liquibase.Scope; -import liquibase.UpdateSummaryEnum; -import liquibase.changelog.ChangeLogParameters; -import liquibase.command.CommandScope; -import liquibase.command.core.UpdateCommandStep; -import liquibase.command.core.helpers.DatabaseChangelogCommandStep; -import liquibase.command.core.helpers.DbUrlConnectionArgumentsCommandStep; -import liquibase.command.core.helpers.ShowSummaryArgument; import liquibase.database.Database; -import liquibase.database.DatabaseFactory; import liquibase.database.core.PostgresDatabase; -import liquibase.database.jvm.JdbcConnection; -import liquibase.exception.DatabaseException; -import liquibase.resource.ClassLoaderResourceAccessor; import liquibase.structure.DatabaseObject; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton -public class DatabaseStartupService implements Managed { +public class DatabaseStartupService { private final UnitOfWork unitOfWork; - private final SessionFactory sessionFactory; private final UserDAO userDAO; private final UserService userService; private final CommaFeedConfiguration config; - @Override - public void start() { - updateSchema(); + public void populateInitialData() { long count = unitOfWork.call(userDAO::count); if (count == 0) { unitOfWork.run(this::initialData); } } - private void updateSchema() { - log.info("checking if database schema needs updating"); - - try (Session session = sessionFactory.openSession()) { - session.doWork(connection -> { - try { - JdbcConnection jdbcConnection = new JdbcConnection(connection); - Database database = getDatabase(jdbcConnection); - - Map scopeObjects = new HashMap<>(); - scopeObjects.put(Scope.Attr.database.name(), database); - scopeObjects.put(Scope.Attr.resourceAccessor.name(), - new ClassLoaderResourceAccessor(Thread.currentThread().getContextClassLoader())); - - Scope.child(scopeObjects, () -> { - CommandScope command = new CommandScope(UpdateCommandStep.COMMAND_NAME); - command.addArgumentValue(DbUrlConnectionArgumentsCommandStep.DATABASE_ARG, database); - command.addArgumentValue(UpdateCommandStep.CHANGELOG_FILE_ARG, "migrations.xml"); - command.addArgumentValue(DatabaseChangelogCommandStep.CHANGELOG_PARAMETERS, new ChangeLogParameters(database)); - command.addArgumentValue(ShowSummaryArgument.SHOW_SUMMARY, UpdateSummaryEnum.OFF); - command.execute(); - }); - - } catch (Exception e) { - throw new RuntimeException(e); - } - }); - } - - log.info("database schema is up to date"); - } - - private Database getDatabase(JdbcConnection connection) throws DatabaseException { - Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(connection); - if (database instanceof PostgresDatabase) { - database = new PostgresDatabase() { - @Override - public String escapeObjectName(String objectName, Class objectType) { - return objectName; - } - }; - database.setConnection(connection); - } - - return database; - } - private void initialData() { log.info("populating database with default values"); try { userService.createAdminUser(); - if (config.getApplicationSettings().getCreateDemoAccount()) { + if (config.users().createDemoAccount()) { userService.createDemoUser(); } } catch (Exception e) { @@ -111,4 +43,20 @@ public class DatabaseStartupService implements Managed { } } + /** + * 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/db/H2MigrationService.java b/commafeed-server/src/main/java/com/commafeed/backend/service/db/H2MigrationService.java deleted file mode 100644 index 75b839c4..00000000 --- a/commafeed-server/src/main/java/com/commafeed/backend/service/db/H2MigrationService.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.commafeed.backend.service.db; - -import java.io.BufferedReader; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.stream.Stream; - -import org.apache.commons.lang3.StringUtils; - -import com.manticore.h2.H2MigrationTool; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@RequiredArgsConstructor -@Slf4j -public class H2MigrationService { - - private static final String H2_FILE_SUFFIX = ".mv.db"; - - public void migrateIfNeeded(Path path, String user, String password) { - if (Files.notExists(path)) { - return; - } - - int format; - try { - format = getH2FileFormat(path); - } catch (IOException e) { - throw new RuntimeException("could not detect H2 format", e); - } - - if (format == 2) { - try { - migrate(path, user, password, "2.1.214", "2.2.224"); - } catch (Exception e) { - throw new RuntimeException("could not migrate H2 to format 3", e); - } - } - } - - public int getH2FileFormat(Path path) throws IOException { - try (BufferedReader reader = Files.newBufferedReader(path)) { - String headers = reader.readLine(); - - return Stream.of(headers.split(",")) - .filter(h -> h.startsWith("format:")) - .map(h -> h.split(":")[1]) - .map(Integer::parseInt) - .findFirst() - .orElseThrow(() -> new RuntimeException("could not find format in H2 file headers")); - } - } - - private void migrate(Path path, String user, String password, String fromVersion, String toVersion) throws Exception { - log.info("migrating H2 database at {} from format {} to format {}", path, fromVersion, toVersion); - - Path scriptPath = path.resolveSibling("script-%d.sql".formatted(System.currentTimeMillis())); - Path newVersionPath = path.resolveSibling("%s.%s%s".formatted(StringUtils.removeEnd(path.getFileName().toString(), H2_FILE_SUFFIX), - getPatchVersion(toVersion), H2_FILE_SUFFIX)); - Path oldVersionBackupPath = path.resolveSibling("%s.%s.backup".formatted(path.getFileName(), getPatchVersion(fromVersion))); - - Files.deleteIfExists(scriptPath); - Files.deleteIfExists(newVersionPath); - Files.deleteIfExists(oldVersionBackupPath); - - H2MigrationTool.readDriverRecords(); - new H2MigrationTool().migrate(fromVersion, toVersion, path.toAbsolutePath().toString(), user, password, - scriptPath.toAbsolutePath().toString(), "", "", false, false, ""); - if (!Files.exists(newVersionPath)) { - throw new RuntimeException("H2 migration failed, new version file not found"); - } - - Files.move(path, oldVersionBackupPath); - Files.move(newVersionPath, path); - Files.delete(oldVersionBackupPath); - Files.delete(scriptPath); - - log.info("migrated H2 database from format {} to format {}", fromVersion, toVersion); - } - - private String getPatchVersion(String version) { - return StringUtils.substringAfterLast(version, "."); - } - -} 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 752ef81b..bd66f02b 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 @@ -7,11 +7,10 @@ import com.commafeed.backend.dao.UnitOfWork; import com.commafeed.backend.dao.UserDAO; import com.commafeed.backend.model.User; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton public class PostLoginActivities { 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 ccd2a5d2..faa2757a 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 @@ -9,12 +9,11 @@ import com.commafeed.backend.dao.UserDAO; import com.commafeed.backend.model.User; import com.commafeed.backend.service.UserService; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton @Slf4j public class DemoAccountCleanupTask extends ScheduledTask { @@ -26,7 +25,7 @@ public class DemoAccountCleanupTask extends ScheduledTask { @Override protected void run() { - if (!config.getApplicationSettings().getCreateDemoAccount()) { + if (!config.users().createDemoAccount()) { return; } 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 5508cbd7..09351381 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 @@ -5,11 +5,10 @@ import java.util.concurrent.TimeUnit; import com.commafeed.CommaFeedConfiguration; import com.commafeed.backend.service.db.DatabaseCleaningService; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton public class EntriesExceedingFeedCapacityCleanupTask extends ScheduledTask { @@ -18,7 +17,7 @@ public class EntriesExceedingFeedCapacityCleanupTask extends ScheduledTask { @Override public void run() { - int maxFeedCapacity = config.getApplicationSettings().getMaxFeedCapacity(); + int maxFeedCapacity = config.database().cleanup().maxFeedCapacity(); if (maxFeedCapacity > 0) { cleaner.cleanEntriesForFeedsExceedingCapacity(maxFeedCapacity); } 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 fad42a75..4bc0cd32 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 @@ -7,11 +7,10 @@ import java.util.concurrent.TimeUnit; import com.commafeed.CommaFeedConfiguration; import com.commafeed.backend.service.db.DatabaseCleaningService; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton public class OldEntriesCleanupTask extends ScheduledTask { @@ -20,9 +19,9 @@ public class OldEntriesCleanupTask extends ScheduledTask { @Override public void run() { - int maxAgeDays = config.getApplicationSettings().getMaxEntriesAgeDays(); - if (maxAgeDays > 0) { - Instant threshold = Instant.now().minus(Duration.ofDays(maxAgeDays)); + Duration entriesMaxAge = config.database().cleanup().entriesMaxAge(); + if (!entriesMaxAge.isZero()) { + Instant threshold = Instant.now().minus(entriesMaxAge); cleaner.cleanEntriesOlderThan(threshold); } } 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 0c19afe3..62e87c90 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 @@ -6,11 +6,10 @@ import java.util.concurrent.TimeUnit; import com.commafeed.CommaFeedConfiguration; import com.commafeed.backend.service.db.DatabaseCleaningService; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton public class OldStatusesCleanupTask extends ScheduledTask { @@ -19,7 +18,7 @@ public class OldStatusesCleanupTask extends ScheduledTask { @Override public void run() { - Instant threshold = config.getApplicationSettings().getUnreadThreshold(); + Instant threshold = config.database().cleanup().statusesInstantThreshold(); if (threshold != null) { cleaner.cleanStatusesOlderThan(threshold); } 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 7b2afcaa..786b7708 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 @@ -4,11 +4,10 @@ import java.util.concurrent.TimeUnit; import com.commafeed.backend.service.db.DatabaseCleaningService; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton public class OrphanedContentsCleanupTask extends ScheduledTask { 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 4606dad3..81eb14ea 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 @@ -4,11 +4,10 @@ import java.util.concurrent.TimeUnit; import com.commafeed.backend.service.db.DatabaseCleaningService; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton public class OrphanedFeedsCleanupTask extends ScheduledTask { 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 new file mode 100644 index 00000000..73a37bf0 --- /dev/null +++ b/commafeed-server/src/main/java/com/commafeed/backend/task/TaskScheduler.java @@ -0,0 +1,28 @@ +package com.commafeed.backend.task; + +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +import io.quarkus.arc.All; +import jakarta.inject.Singleton; + +@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/InPageReferenceFeedURLProvider.java b/commafeed-server/src/main/java/com/commafeed/backend/urlprovider/InPageReferenceFeedURLProvider.java index 36913423..b2bf773c 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 @@ -4,6 +4,9 @@ import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.select.Elements; +import jakarta.inject.Singleton; + +@Singleton public class InPageReferenceFeedURLProvider implements FeedURLProvider { @Override 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 ab94a1c0..882da01f 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 @@ -3,12 +3,15 @@ package com.commafeed.backend.urlprovider; import java.util.regex.Matcher; import java.util.regex.Pattern; +import jakarta.inject.Singleton; + /** * 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 Pattern REGEXP = Pattern.compile("(.*\\byoutube\\.com)\\/channel\\/([^\\/]+)", Pattern.CASE_INSENSITIVE); diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/auth/SecurityCheck.java b/commafeed-server/src/main/java/com/commafeed/frontend/auth/SecurityCheck.java deleted file mode 100644 index 0e685686..00000000 --- a/commafeed-server/src/main/java/com/commafeed/frontend/auth/SecurityCheck.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.commafeed.frontend.auth; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Inherited; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -import com.commafeed.backend.model.UserRole.Role; - -@Inherited -@Target({ ElementType.PARAMETER }) -@Retention(RetentionPolicy.RUNTIME) -public @interface SecurityCheck { - - /** - * Roles needed. - */ - Role value() default Role.USER; - - boolean apiKeyAllowed() default false; -} \ No newline at end of file diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/auth/SecurityCheckFactory.java b/commafeed-server/src/main/java/com/commafeed/frontend/auth/SecurityCheckFactory.java deleted file mode 100644 index 2e14bd7a..00000000 --- a/commafeed-server/src/main/java/com/commafeed/frontend/auth/SecurityCheckFactory.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.commafeed.frontend.auth; - -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.function.Function; - -import org.glassfish.jersey.server.ContainerRequest; - -import com.commafeed.CommaFeedConfiguration; -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.frontend.session.SessionHelper; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.ws.rs.WebApplicationException; -import jakarta.ws.rs.core.HttpHeaders; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -public class SecurityCheckFactory implements Function { - - private static final String PREFIX = "Basic"; - - private final UserDAO userDAO; - private final UserService userService; - private final CommaFeedConfiguration config; - private final HttpServletRequest request; - private final Role role; - private final boolean apiKeyAllowed; - - @Override - public User apply(ContainerRequest req) { - Optional user = apiKeyLogin(); - if (user.isEmpty()) { - user = basicAuthenticationLogin(); - } - if (user.isEmpty()) { - user = cookieSessionLogin(new SessionHelper(request)); - } - - if (user.isPresent()) { - Set roles = userService.getRoles(user.get()); - if (roles.contains(role)) { - return user.get(); - } else { - throw buildWebApplicationException(Response.Status.FORBIDDEN, "You don't have the required role to access this resource."); - } - } else { - throw buildWebApplicationException(Response.Status.UNAUTHORIZED, "Credentials are required to access this resource."); - } - } - - Optional cookieSessionLogin(SessionHelper sessionHelper) { - Optional loggedInUser = sessionHelper.getLoggedInUserId().map(userDAO::findById); - loggedInUser.ifPresent(userService::performPostLoginActivities); - return loggedInUser; - } - - private Optional basicAuthenticationLogin() { - String header = request.getHeader(HttpHeaders.AUTHORIZATION); - if (header != null) { - int space = header.indexOf(' '); - if (space > 0) { - String method = header.substring(0, space); - if (PREFIX.equalsIgnoreCase(method)) { - byte[] decodedBytes = Base64.getDecoder().decode(header.substring(space + 1)); - String decoded = new String(decodedBytes, StandardCharsets.ISO_8859_1); - int i = decoded.indexOf(':'); - if (i > 0) { - String username = decoded.substring(0, i); - String password = decoded.substring(i + 1); - return userService.login(username, password); - } - } - } - } - return Optional.empty(); - } - - private Optional apiKeyLogin() { - String apiKey = request.getParameter("apiKey"); - if (apiKey != null && apiKeyAllowed) { - return userService.login(apiKey); - } - return Optional.empty(); - } - - private WebApplicationException buildWebApplicationException(Response.Status status, String message) { - Map response = new HashMap<>(); - response.put("message", message); - response.put("allowRegistrations", config.getApplicationSettings().getAllowRegistrations()); - return new WebApplicationException(Response.status(status).entity(response).type(MediaType.APPLICATION_JSON).build()); - } - -} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/auth/SecurityCheckFactoryProvider.java b/commafeed-server/src/main/java/com/commafeed/frontend/auth/SecurityCheckFactoryProvider.java deleted file mode 100644 index 4e70d479..00000000 --- a/commafeed-server/src/main/java/com/commafeed/frontend/auth/SecurityCheckFactoryProvider.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.commafeed.frontend.auth; - -import java.util.function.Function; - -import org.glassfish.hk2.utilities.binding.AbstractBinder; -import org.glassfish.jersey.server.ContainerRequest; -import org.glassfish.jersey.server.internal.inject.AbstractValueParamProvider; -import org.glassfish.jersey.server.internal.inject.MultivaluedParameterExtractorProvider; -import org.glassfish.jersey.server.model.Parameter; -import org.glassfish.jersey.server.spi.internal.ValueParamProvider; - -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.dao.UserDAO; -import com.commafeed.backend.model.User; -import com.commafeed.backend.service.UserService; - -import jakarta.inject.Inject; -import jakarta.inject.Singleton; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; - -@Singleton -public class SecurityCheckFactoryProvider extends AbstractValueParamProvider { - - private final UserService userService; - private final UserDAO userDAO; - private final CommaFeedConfiguration config; - private final HttpServletRequest request; - - @Inject - public SecurityCheckFactoryProvider(final MultivaluedParameterExtractorProvider extractorProvider, UserDAO userDAO, - UserService userService, CommaFeedConfiguration config, HttpServletRequest request) { - super(() -> extractorProvider, Parameter.Source.UNKNOWN); - this.userDAO = userDAO; - this.userService = userService; - this.config = config; - this.request = request; - } - - @Override - protected Function createValueProvider(Parameter parameter) { - final Class classType = parameter.getRawType(); - - SecurityCheck securityCheck = parameter.getAnnotation(SecurityCheck.class); - if (securityCheck == null) { - return null; - } - - if (!classType.isAssignableFrom(User.class)) { - return null; - } - - return new SecurityCheckFactory(userDAO, userService, config, request, securityCheck.value(), securityCheck.apiKeyAllowed()); - } - - @RequiredArgsConstructor - public static class Binder extends AbstractBinder { - - private final UserDAO userDAO; - private final UserService userService; - private final CommaFeedConfiguration config; - - @Override - protected void configure() { - bind(SecurityCheckFactoryProvider.class).to(ValueParamProvider.class).in(Singleton.class); - bind(userDAO).to(UserDAO.class); - bind(userService).to(UserService.class); - bind(config).to(CommaFeedConfiguration.class); - } - } - -} 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 c360d7cd..631e56a7 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 @@ -4,6 +4,7 @@ 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; @@ -11,6 +12,7 @@ import lombok.Data; @SuppressWarnings("serial") @Schema(description = "Entry details") @Data +@RegisterForReflection public class Category implements Serializable { @Schema(description = "category id", requiredMode = RequiredMode.REQUIRED) 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 9e7d6483..9d51ee62 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 @@ -4,6 +4,7 @@ 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; @@ -11,6 +12,7 @@ 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) 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 87624d77..b7add91f 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 @@ -19,6 +19,7 @@ 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; @@ -26,6 +27,7 @@ import lombok.Data; @SuppressWarnings("serial") @Schema(description = "Entry details") @Data +@RegisterForReflection public class Entry implements Serializable { @Schema(description = "entry id", requiredMode = RequiredMode.REQUIRED) 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 ffc4cca3..643ad6a5 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 @@ -2,6 +2,7 @@ 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; @@ -9,6 +10,7 @@ import lombok.Data; @SuppressWarnings("serial") @Schema(description = "Feed details") @Data +@RegisterForReflection public class FeedInfo implements Serializable { @Schema(description = "url", requiredMode = RequiredMode.REQUIRED) 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 0eee703b..f7ffd7cf 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 @@ -2,6 +2,7 @@ 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; @@ -9,6 +10,7 @@ import lombok.Data; @SuppressWarnings("serial") @Schema(description = "Server infos") @Data +@RegisterForReflection public class ServerInfo implements Serializable { @Schema 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 28850422..e28412f0 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 @@ -2,6 +2,7 @@ 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; @@ -9,6 +10,7 @@ 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) 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 1a022af2..902de115 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 @@ -8,6 +8,7 @@ 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; @@ -15,6 +16,7 @@ import lombok.Data; @SuppressWarnings("serial") @Schema(description = "User information") @Data +@RegisterForReflection public class Subscription implements Serializable { @Schema(description = "subscription id", requiredMode = RequiredMode.REQUIRED) 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 4f946ea2..6ecd7a1a 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 @@ -3,12 +3,14 @@ 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 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 7c7d332a..43d5faea 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 @@ -3,6 +3,7 @@ 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; @@ -10,6 +11,7 @@ import lombok.Data; @SuppressWarnings("serial") @Schema(description = "User information") @Data +@RegisterForReflection public class UserModel implements Serializable { @Schema(description = "user id", requiredMode = RequiredMode.REQUIRED) diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/LoginRequest.java b/commafeed-server/src/main/java/com/commafeed/frontend/model/request/LoginRequest.java deleted file mode 100644 index c5dc4c98..00000000 --- a/commafeed-server/src/main/java/com/commafeed/frontend/model/request/LoginRequest.java +++ /dev/null @@ -1,24 +0,0 @@ -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 jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.Size; -import lombok.Data; - -@SuppressWarnings("serial") -@Data -@Schema -public class LoginRequest implements Serializable { - - @Schema(description = "username", requiredMode = RequiredMode.REQUIRED) - @Size(min = 3, max = 32) - private String name; - - @Schema(description = "password", requiredMode = RequiredMode.REQUIRED) - @NotEmpty - @Size(max = 128) - private String password; -} 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 c066a44d..1c2e1612 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 @@ -2,7 +2,7 @@ package com.commafeed.frontend.model.request; import java.io.Serializable; -import com.commafeed.frontend.auth.ValidPassword; +import com.commafeed.security.password.ValidPassword; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; 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 bdf84ec9..cc27052e 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 @@ -2,7 +2,7 @@ package com.commafeed.frontend.model.request; import java.io.Serializable; -import com.commafeed.frontend.auth.ValidPassword; +import com.commafeed.security.password.ValidPassword; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; 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 1eccadb7..7f4f2c27 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 @@ -7,10 +7,7 @@ import java.util.Set; import org.apache.commons.lang3.StringUtils; import com.codahale.metrics.MetricRegistry; -import com.codahale.metrics.annotation.Timed; import com.commafeed.CommaFeedApplication; -import com.commafeed.CommaFeedConfiguration; -import com.commafeed.CommaFeedConfiguration.ApplicationSettings; import com.commafeed.backend.dao.UserDAO; import com.commafeed.backend.dao.UserRoleDAO; import com.commafeed.backend.model.User; @@ -18,14 +15,14 @@ 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.auth.SecurityCheck; 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.dropwizard.hibernate.UnitOfWork; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; @@ -33,8 +30,9 @@ 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 jakarta.inject.Inject; +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; @@ -46,30 +44,29 @@ import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; import lombok.RequiredArgsConstructor; -@Path("/admin") +@Path("/rest/admin") +@RolesAllowed(Roles.ADMIN) @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@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 CommaFeedConfiguration config; private final MetricRegistry metrics; @Path("/user/save") @POST - @UnitOfWork + @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") - @Timed - public Response adminSaveUser(@Parameter(hidden = true) @SecurityCheck(Role.ADMIN) User user, - @Parameter(required = true) AdminSaveUserRequest req) { + public Response adminSaveUser(@Parameter(required = true) AdminSaveUserRequest req) { Preconditions.checkNotNull(req); Preconditions.checkNotNull(req.getName()); @@ -87,6 +84,7 @@ public class AdminREST { 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(); } @@ -121,14 +119,12 @@ public class AdminREST { @Path("/user/get/{id}") @GET - @UnitOfWork + @Transactional @Operation( summary = "Get user information", description = "Get user information", responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = UserModel.class))) }) - @Timed - public Response adminGetUser(@Parameter(hidden = true) @SecurityCheck(Role.ADMIN) User user, - @Parameter(description = "user id", required = true) @PathParam("id") Long id) { + 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(); @@ -142,13 +138,12 @@ public class AdminREST { @Path("/user/getAll") @GET - @UnitOfWork + @Transactional @Operation( summary = "Get all users", description = "Get all users", responses = { @ApiResponse(content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserModel.class)))) }) - @Timed - public Response adminGetUsers(@Parameter(hidden = true) @SecurityCheck(Role.ADMIN) User user) { + public Response adminGetUsers() { Map users = new HashMap<>(); for (UserRole role : userRoleDAO.findAll()) { User u = role.getUser(); @@ -173,11 +168,9 @@ public class AdminREST { @Path("/user/delete") @POST - @UnitOfWork + @Transactional @Operation(summary = "Delete a user", description = "Delete a user, and all his subscriptions") - @Timed - public Response adminDeleteUser(@Parameter(hidden = true) @SecurityCheck(Role.ADMIN) User user, - @Parameter(required = true) IDRequest req) { + public Response adminDeleteUser(@Parameter(required = true) IDRequest req) { Preconditions.checkNotNull(req); Preconditions.checkNotNull(req.getId()); @@ -185,6 +178,8 @@ public class AdminREST { 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(); } @@ -192,24 +187,11 @@ public class AdminREST { return Response.ok().build(); } - @Path("/settings") - @GET - @UnitOfWork - @Operation( - summary = "Retrieve application settings", - description = "Retrieve application settings", - responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = ApplicationSettings.class))) }) - @Timed - public Response getApplicationSettings(@Parameter(hidden = true) @SecurityCheck(Role.ADMIN) User user) { - return Response.ok(config.getApplicationSettings()).build(); - } - @Path("/metrics") @GET - @UnitOfWork + @Transactional @Operation(summary = "Retrieve server metrics") - @Timed - public Response getMetrics(@Parameter(hidden = true) @SecurityCheck(Role.ADMIN) User user) { + 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 374f03a4..0d83332d 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 @@ -13,9 +13,7 @@ import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; -import com.codahale.metrics.annotation.Timed; import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.cache.CacheService; import com.commafeed.backend.dao.FeedCategoryDAO; import com.commafeed.backend.dao.FeedEntryStatusDAO; import com.commafeed.backend.dao.FeedSubscriptionDAO; @@ -29,7 +27,6 @@ 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.auth.SecurityCheck; import com.commafeed.frontend.model.Category; import com.commafeed.frontend.model.Entries; import com.commafeed.frontend.model.Entry; @@ -40,13 +37,14 @@ 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.dropwizard.hibernate.UnitOfWork; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; @@ -54,8 +52,9 @@ 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 jakarta.inject.Inject; +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; @@ -67,14 +66,16 @@ 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 lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -@Path("/category") +@Path("/rest/category") +@RolesAllowed(Roles.USER) @Slf4j @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton @Tag(name = "Feed categories") public class CategoryREST { @@ -82,23 +83,23 @@ 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 CacheService cache; private final CommaFeedConfiguration config; + private final UriInfo uri; @Path("/entries") @GET - @UnitOfWork + @Transactional @Operation( summary = "Get category entries", description = "Get a list of category entries", responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Entries.class))) }) - @Timed - public Response getCategoryEntries(@Parameter(hidden = true) @SecurityCheck(apiKeyAllowed = true) User user, + 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", @@ -137,22 +138,24 @@ public class CategoryREST { 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.getApplicationSettings().getImageProxyEnabled())); + 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.getApplicationSettings().getImageProxyEnabled())); + entries.getEntries().add(Entry.build(status, config.imageProxyEnabled())); } } else { FeedCategory parent = feedCategoryDAO.findById(user, Long.valueOf(id)); @@ -164,7 +167,7 @@ public class CategoryREST { offset, limit + 1, order, true, tag, null, null); for (FeedEntryStatus status : list) { - entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getImageProxyEnabled())); + entries.getEntries().add(Entry.build(status, config.imageProxyEnabled())); } entries.setName(parent.getName()); } else { @@ -186,11 +189,10 @@ public class CategoryREST { @Path("/entriesAsFeed") @GET - @UnitOfWork + @Transactional @Operation(summary = "Get category entries as feed", description = "Get a feed of category entries") @Produces(MediaType.APPLICATION_XML) - @Timed - public Response getCategoryEntriesAsFeed(@Parameter(hidden = true) @SecurityCheck(apiKeyAllowed = true) User user, + 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", @@ -205,7 +207,7 @@ public class CategoryREST { 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(user, id, readType, newerThan, offset, limit, order, keywords, excludedSubscriptionIds, tag); + Response response = getCategoryEntries(id, readType, newerThan, offset, limit, order, keywords, excludedSubscriptionIds, tag); if (response.getStatus() != Status.OK.getStatusCode()) { return response; } @@ -215,7 +217,7 @@ public class CategoryREST { feed.setFeedType("rss_2.0"); feed.setTitle("CommaFeed - " + entries.getName()); feed.setDescription("CommaFeed - " + entries.getName()); - feed.setLink(config.getApplicationSettings().getPublicUrl()); + feed.setLink(uri.getBaseUri().toString()); feed.setEntries(entries.getEntries().stream().map(Entry::asRss).toList()); SyndFeedOutput output = new SyndFeedOutput(); @@ -231,11 +233,9 @@ public class CategoryREST { @Path("/mark") @POST - @UnitOfWork + @Transactional @Operation(summary = "Mark category entries", description = "Mark feed entries of this category as read") - @Timed - public Response markCategoryEntries(@Parameter(hidden = true) @SecurityCheck User user, - @Valid @Parameter(description = "category id, or 'all'", required = true) MarkRequest req) { + public Response markCategoryEntries(@Valid @Parameter(description = "category id, or 'all'", required = true) MarkRequest req) { Preconditions.checkNotNull(req); Preconditions.checkNotNull(req.getId()); @@ -244,6 +244,7 @@ public class CategoryREST { 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()); @@ -268,17 +269,17 @@ public class CategoryREST { @Path("/add") @POST - @UnitOfWork + @Transactional @Operation( summary = "Add a category", description = "Add a new feed category", responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Long.class))) }) - @Timed - public Response addCategory(@Parameter(hidden = true) @SecurityCheck User user, - @Valid @Parameter(required = true) AddCategoryRequest req) { + 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); @@ -290,20 +291,19 @@ public class CategoryREST { cat.setParent(parent); } feedCategoryDAO.saveOrUpdate(cat); - cache.invalidateUserRootCategory(user); return Response.ok(cat.getId()).build(); } @POST @Path("/delete") - @UnitOfWork + @Transactional @Operation(summary = "Delete a category", description = "Delete an existing feed category") - @Timed - public Response deleteCategory(@Parameter(hidden = true) @SecurityCheck User user, @Parameter(required = true) IDRequest req) { + 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); @@ -320,7 +320,6 @@ public class CategoryREST { feedCategoryDAO.saveOrUpdate(categories); feedCategoryDAO.delete(cat); - cache.invalidateUserRootCategory(user); return Response.ok().build(); } else { return Response.status(Status.NOT_FOUND).build(); @@ -329,14 +328,13 @@ public class CategoryREST { @POST @Path("/modify") - @UnitOfWork + @Transactional @Operation(summary = "Rename a category", description = "Rename an existing feed category") - @Timed - public Response modifyCategory(@Parameter(hidden = true) @SecurityCheck User user, - @Valid @Parameter(required = true) CategoryModificationRequest req) { + 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())) { @@ -374,62 +372,56 @@ public class CategoryREST { } feedCategoryDAO.saveOrUpdate(category); - cache.invalidateUserRootCategory(user); return Response.ok().build(); } @POST @Path("/collapse") - @UnitOfWork + @Transactional @Operation(summary = "Collapse a category", description = "Save collapsed or expanded status for a category") - @Timed - public Response collapseCategory(@Parameter(hidden = true) @SecurityCheck User user, @Parameter(required = true) CollapseRequest req) { + 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); - cache.invalidateUserRootCategory(user); return Response.ok().build(); } @GET @Path("/unreadCount") - @UnitOfWork + @Transactional @Operation( summary = "Get unread count for feed subscriptions", responses = { @ApiResponse(content = @Content(array = @ArraySchema(schema = @Schema(implementation = UnreadCount.class)))) }) - @Timed - public Response getUnreadCount(@Parameter(hidden = true) @SecurityCheck(apiKeyAllowed = true) User user) { + public Response getUnreadCount() { + User user = authenticationContext.getCurrentUser(); Map unreadCount = feedSubscriptionService.getUnreadCount(user); return Response.ok(Lists.newArrayList(unreadCount.values())).build(); } @GET @Path("/get") - @UnitOfWork + @Transactional @Operation( summary = "Get root category", description = "Get all categories and subscriptions of the user", responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Category.class))) }) - @Timed - public Response getRootCategory(@Parameter(hidden = true) @SecurityCheck User user) { - Category root = cache.getUserRootCategory(user); - if (root == null) { - log.debug("tree cache miss for {}", user.getId()); - List categories = feedCategoryDAO.findAll(user); - List subscriptions = feedSubscriptionDAO.findAll(user); - Map unreadCount = feedSubscriptionService.getUnreadCount(user); + public Response getRootCategory() { + User user = authenticationContext.getCurrentUser(); - root = buildCategory(null, categories, subscriptions, unreadCount); - root.setId("all"); - root.setName("All"); - cache.setUserRootCategory(user, root); - } + 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(); } 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 2726f4d2..1ebe6954 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 @@ -2,24 +2,24 @@ package com.commafeed.frontend.resource; import java.util.List; -import com.codahale.metrics.annotation.Timed; 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.auth.SecurityCheck; 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.dropwizard.hibernate.UnitOfWork; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.inject.Inject; +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; @@ -30,42 +30,42 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; -@Path("/entry") +@Path("/rest/entry") +@RolesAllowed(Roles.USER) @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@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 - @UnitOfWork + @Transactional @Operation(summary = "Mark a feed entry", description = "Mark a feed entry as read/unread") - @Timed - public Response markEntry(@Parameter(hidden = true) @SecurityCheck User user, - @Valid @Parameter(description = "Mark Request", required = true) MarkRequest req) { + 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 - @UnitOfWork + @Transactional @Operation(summary = "Mark multiple feed entries", description = "Mark feed entries as read/unread") - @Timed - public Response markEntries(@Parameter(hidden = true) @SecurityCheck User user, - @Valid @Parameter(description = "Multiple Mark Request", required = true) MultipleMarkRequest req) { + 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()); @@ -76,15 +76,14 @@ public class EntryREST { @Path("/star") @POST - @UnitOfWork + @Transactional @Operation(summary = "Star a feed entry", description = "Mark a feed entry as read/unread") - @Timed - public Response starEntry(@Parameter(hidden = true) @SecurityCheck User user, - @Valid @Parameter(description = "Star Request", required = true) StarRequest req) { + 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(); @@ -92,24 +91,23 @@ public class EntryREST { @Path("/tags") @GET - @UnitOfWork + @Transactional @Operation(summary = "Get list of tags for the user", description = "Get list of tags for the user") - @Timed - public Response getTags(@Parameter(hidden = true) @SecurityCheck User user) { + public Response getTags() { + User user = authenticationContext.getCurrentUser(); List tags = feedEntryTagDAO.findByUser(user); return Response.ok(tags).build(); } @Path("/tag") @POST - @UnitOfWork + @Transactional @Operation(summary = "Set feed entry tags") - @Timed - public Response tagEntry(@Parameter(hidden = true) @SecurityCheck User user, - @Valid @Parameter(description = "Tag Request", required = true) TagRequest req) { + 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 0d25fa42..9c233817 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,24 +1,20 @@ package com.commafeed.frontend.resource; -import java.io.InputStream; import java.io.StringWriter; -import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Instant; -import java.util.Calendar; import java.util.Collections; -import java.util.Date; import java.util.List; import java.util.Objects; import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; -import org.glassfish.jersey.media.multipart.FormDataParam; +import org.apache.commons.lang3.SystemUtils; +import org.jboss.resteasy.reactive.Cache; +import org.jboss.resteasy.reactive.RestForm; -import com.codahale.metrics.annotation.Timed; import com.commafeed.CommaFeedApplication; import com.commafeed.CommaFeedConfiguration; -import com.commafeed.backend.cache.CacheService; import com.commafeed.backend.dao.FeedCategoryDAO; import com.commafeed.backend.dao.FeedEntryStatusDAO; import com.commafeed.backend.dao.FeedSubscriptionDAO; @@ -44,7 +40,6 @@ import com.commafeed.backend.service.FeedEntryFilteringService.FeedEntryFilterEx import com.commafeed.backend.service.FeedEntryService; import com.commafeed.backend.service.FeedService; import com.commafeed.backend.service.FeedSubscriptionService; -import com.commafeed.frontend.auth.SecurityCheck; import com.commafeed.frontend.model.Entries; import com.commafeed.frontend.model.Entry; import com.commafeed.frontend.model.FeedInfo; @@ -55,6 +50,8 @@ 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; @@ -63,15 +60,15 @@ import com.rometools.rome.feed.synd.SyndFeedImpl; import com.rometools.rome.io.SyndFeedOutput; import com.rometools.rome.io.WireFeedOutput; -import io.dropwizard.hibernate.UnitOfWork; 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 jakarta.inject.Inject; +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; @@ -82,25 +79,26 @@ 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.CacheControl; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.Response.ResponseBuilder; import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.core.UriInfo; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -@Path("/feed") +@Path("/rest/feed") +@RolesAllowed(Roles.USER) @Slf4j @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@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; @@ -112,8 +110,8 @@ public class FeedREST { private final FeedRefreshEngine feedRefreshEngine; private final OPMLImporter opmlImporter; private final OPMLExporter opmlExporter; - private final CacheService cache; private final CommaFeedConfiguration config; + private final UriInfo uri; private static FeedEntry initTestEntry() { FeedEntry entry = new FeedEntry(); @@ -129,14 +127,12 @@ public class FeedREST { @Path("/entries") @GET - @UnitOfWork + @Transactional @Operation( summary = "Get feed entries", description = "Get a list of feed entries", responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Entries.class))) }) - @Timed - public Response getFeedEntries(@Parameter(hidden = true) @SecurityCheck(apiKeyAllowed = true) User user, - @Parameter(description = "id of the feed", required = true) @QueryParam("id") String id, + 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, @@ -164,6 +160,7 @@ public class FeedREST { 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()); @@ -175,7 +172,7 @@ public class FeedREST { entryKeywords, newerThanDate, offset, limit + 1, order, true, null, null, null); for (FeedEntryStatus status : list) { - entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getImageProxyEnabled())); + entries.getEntries().add(Entry.build(status, config.imageProxyEnabled())); } boolean hasMore = entries.getEntries().size() > limit; @@ -195,12 +192,10 @@ public class FeedREST { @Path("/entriesAsFeed") @GET - @UnitOfWork + @Transactional @Operation(summary = "Get feed entries as a feed", description = "Get a feed of feed entries") @Produces(MediaType.APPLICATION_XML) - @Timed - public Response getFeedEntriesAsFeed(@Parameter(hidden = true) @SecurityCheck(apiKeyAllowed = true) User user, - @Parameter(description = "id of the feed", required = true) @QueryParam("id") String id, + 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, @@ -210,7 +205,7 @@ public class FeedREST { @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(user, id, readType, newerThan, offset, limit, order, keywords); + Response response = getFeedEntries(id, readType, newerThan, offset, limit, order, keywords); if (response.getStatus() != Status.OK.getStatusCode()) { return response; } @@ -220,7 +215,7 @@ public class FeedREST { feed.setFeedType("rss_2.0"); feed.setTitle("CommaFeed - " + entries.getName()); feed.setDescription("CommaFeed - " + entries.getName()); - feed.setLink(config.getApplicationSettings().getPublicUrl()); + feed.setLink(uri.getBaseUri().toString()); feed.setEntries(entries.getEntries().stream().map(Entry::asRss).toList()); SyndFeedOutput output = new SyndFeedOutput(); @@ -253,14 +248,12 @@ public class FeedREST { @POST @Path("/fetch") - @UnitOfWork + @Transactional @Operation( summary = "Fetch a feed", description = "Fetch a feed by its url", responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = FeedInfo.class))) }) - @Timed - public Response fetchFeed(@Parameter(hidden = true) @SecurityCheck User user, - @Valid @Parameter(description = "feed url", required = true) FeedInfoRequest req) { + public Response fetchFeed(@Valid @Parameter(description = "feed url", required = true) FeedInfoRequest req) { Preconditions.checkNotNull(req); Preconditions.checkNotNull(req.getUrl()); @@ -279,25 +272,23 @@ public class FeedREST { @Path("/refreshAll") @GET - @UnitOfWork + @Transactional @Operation(summary = "Queue all feeds of the user for refresh", description = "Manually add all feeds of the user to the refresh queue") - @Timed - public Response queueAllForRefresh(@Parameter(hidden = true) @SecurityCheck User user) { + public Response queueAllForRefresh() { + User user = authenticationContext.getCurrentUser(); feedSubscriptionService.refreshAll(user); return Response.ok().build(); } @Path("/refresh") @POST - @UnitOfWork + @Transactional @Operation(summary = "Queue a feed for refresh", description = "Manually add a feed to the refresh queue") - @Timed - public Response queueForRefresh(@Parameter(hidden = true) @SecurityCheck User user, - @Parameter(description = "Feed id", required = true) IDRequest req) { - + 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(); @@ -309,11 +300,9 @@ public class FeedREST { @Path("/mark") @POST - @UnitOfWork + @Transactional @Operation(summary = "Mark feed entries", description = "Mark feed entries as read (unread is not supported)") - @Timed - public Response markFeedEntries(@Parameter(hidden = true) @SecurityCheck User user, - @Valid @Parameter(description = "Mark request", required = true) MarkRequest req) { + public Response markFeedEntries(@Valid @Parameter(description = "Mark request", required = true) MarkRequest req) { Preconditions.checkNotNull(req); Preconditions.checkNotNull(req.getId()); @@ -322,6 +311,7 @@ public class FeedREST { 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, @@ -332,15 +322,14 @@ public class FeedREST { @GET @Path("/get/{id}") - @UnitOfWork + @Transactional @Operation( summary = "get feed", responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Subscription.class))) }) - @Timed - public Response getFeed(@Parameter(hidden = true) @SecurityCheck User user, - @Parameter(description = "user id", required = true) @PathParam("id") Long id) { - + 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(); @@ -351,13 +340,12 @@ public class FeedREST { @GET @Path("/favicon/{id}") - @UnitOfWork + @Cache(maxAge = 2592000) @Operation(summary = "Fetch a feed's icon", description = "Fetch a feed's icon") - @Timed - public Response getFeedFavicon(@Parameter(hidden = true) @SecurityCheck User user, - @Parameter(description = "subscription id", required = true) @PathParam("id") Long id) { - + 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(); @@ -365,31 +353,17 @@ public class FeedREST { Feed feed = subscription.getFeed(); Favicon icon = feedService.fetchFavicon(feed); - ResponseBuilder builder = Response.ok(icon.getIcon(), icon.getMediaType()); - - CacheControl cacheControl = new CacheControl(); - cacheControl.setMaxAge(2592000); - cacheControl.setPrivate(false); - builder.cacheControl(cacheControl); - - Calendar calendar = Calendar.getInstance(); - calendar.add(Calendar.MONTH, 1); - builder.expires(calendar.getTime()); - builder.lastModified(Date.from(CommaFeedApplication.STARTUP_TIME)); - - return builder.build(); + return Response.ok(icon.getIcon(), icon.getMediaType()).build(); } @POST @Path("/subscribe") - @UnitOfWork + @Transactional @Operation( summary = "Subscribe to a feed", description = "Subscribe to a feed", responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Long.class))) }) - @Timed - public Response subscribe(@Parameter(hidden = true) @SecurityCheck User user, - @Valid @Parameter(description = "subscription request", required = true) SubscribeRequest req) { + public Response subscribe(@Valid @Parameter(description = "subscription request", required = true) SubscribeRequest req) { Preconditions.checkNotNull(req); Preconditions.checkNotNull(req.getTitle()); Preconditions.checkNotNull(req.getUrl()); @@ -401,6 +375,7 @@ public class FeedREST { } 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) { @@ -413,19 +388,18 @@ public class FeedREST { @GET @Path("/subscribe") - @UnitOfWork + @Transactional @Operation(summary = "Subscribe to a feed", description = "Subscribe to a feed") - @Timed - public Response subscribeFromUrl(@Parameter(hidden = true) @SecurityCheck User user, - @Parameter(description = "feed url", required = true) @QueryParam("url") String url) { + 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.create(config.getApplicationSettings().getPublicUrl())).build(); + return Response.temporaryRedirect(uri.getBaseUri()).build(); } private String prependHttp(String url) { @@ -437,13 +411,13 @@ public class FeedREST { @POST @Path("/unsubscribe") - @UnitOfWork + @Transactional @Operation(summary = "Unsubscribe from a feed", description = "Unsubscribe from a feed") - @Timed - public Response unsubscribe(@Parameter(hidden = true) @SecurityCheck User user, @Parameter(required = true) IDRequest req) { + 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(); @@ -454,11 +428,9 @@ public class FeedREST { @POST @Path("/modify") - @UnitOfWork + @Transactional @Operation(summary = "Modify a subscription", description = "Modify a feed subscription") - @Timed - public Response modifyFeed(@Parameter(hidden = true) @SecurityCheck User user, - @Valid @Parameter(description = "subscription id", required = true) FeedModificationRequest req) { + public Response modifyFeed(@Valid @Parameter(description = "subscription id", required = true) FeedModificationRequest req) { Preconditions.checkNotNull(req); Preconditions.checkNotNull(req.getId()); @@ -468,6 +440,7 @@ public class FeedREST { 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())); @@ -503,45 +476,42 @@ public class FeedREST { } else { feedSubscriptionDAO.saveOrUpdate(subscription); } - cache.invalidateUserRootCategory(user); return Response.ok().build(); } @POST @Path("/import") - @UnitOfWork + @Transactional @Consumes(MediaType.MULTIPART_FORM_DATA) @Operation(summary = "OPML import", description = "Import an OPML file, posted as a FORM with the 'file' name") - @Timed - public Response importOpml(@Parameter(hidden = true) @SecurityCheck User user, - @Parameter(description = "ompl file", required = true) @FormDataParam("file") InputStream input) { + 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 { - String opml = new String(input.readAllBytes(), StandardCharsets.UTF_8); - opmlImporter.importOpml(user, opml); + // 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) { - log.error(e.getMessage(), e); - throw new WebApplicationException(Response.status(Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).build()); + return Response.status(Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).build(); } return Response.ok().build(); } @GET @Path("/export") - @UnitOfWork + @Transactional @Produces(MediaType.APPLICATION_XML) @Operation(summary = "OPML export", description = "Export an OPML file of the user's subscriptions") - @Timed - public Response exportOpml(@Parameter(hidden = true) @SecurityCheck User user) { + 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).build(); + 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/ServerREST.java b/commafeed-server/src/main/java/com/commafeed/frontend/resource/ServerREST.java index eb213f50..272b79f1 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,25 +1,23 @@ package com.commafeed.frontend.resource; -import org.apache.commons.lang3.StringUtils; - -import com.codahale.metrics.annotation.Timed; 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.backend.model.User; -import com.commafeed.frontend.auth.SecurityCheck; import com.commafeed.frontend.model.ServerInfo; +import com.commafeed.security.Roles; -import io.dropwizard.hibernate.UnitOfWork; 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 jakarta.inject.Inject; +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; @@ -30,55 +28,56 @@ import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; import lombok.RequiredArgsConstructor; -@Path("/server") +@Path("/rest/server") + @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton @Tag(name = "Server") public class ServerREST { private final HttpGetter httpGetter; private final CommaFeedConfiguration config; + private final CommaFeedVersion version; @Path("/get") @GET - @UnitOfWork + @PermitAll + @Transactional @Operation( summary = "Get server infos", description = "Get server infos", responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = ServerInfo.class))) }) - @Timed public Response getServerInfos() { ServerInfo infos = new ServerInfo(); - infos.setAnnouncement(config.getApplicationSettings().getAnnouncement()); - infos.setVersion(config.getVersion()); - infos.setGitCommit(config.getGitCommit()); - infos.setAllowRegistrations(config.getApplicationSettings().getAllowRegistrations()); - infos.setGoogleAnalyticsCode(config.getApplicationSettings().getGoogleAnalyticsTrackingCode()); - infos.setSmtpEnabled(StringUtils.isNotBlank(config.getApplicationSettings().getSmtpHost())); - infos.setDemoAccountEnabled(config.getApplicationSettings().getCreateDemoAccount()); - infos.setWebsocketEnabled(config.getApplicationSettings().getWebsocketEnabled()); - infos.setWebsocketPingInterval(config.getApplicationSettings().getWebsocketPingInterval().toMilliseconds()); - infos.setTreeReloadInterval(config.getApplicationSettings().getTreeReloadInterval().toMilliseconds()); + 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()); return Response.ok(infos).build(); } @Path("/proxy") @GET - @UnitOfWork + @RolesAllowed(Roles.USER) + @Transactional @Operation(summary = "proxy image") @Produces("image/png") - @Timed - public Response getProxiedImage(@Parameter(hidden = true) @SecurityCheck User user, - @Parameter(description = "image url", required = true) @QueryParam("u") String url) { - if (!config.getApplicationSettings().getImageProxyEnabled()) { + 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.getBinary(url, 20000); + HttpResult result = httpGetter.getBinary(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 3ab79050..22744fe7 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 @@ -10,7 +10,6 @@ import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.StringUtils; import org.apache.hc.core5.net.URIBuilder; -import com.codahale.metrics.annotation.Timed; import com.commafeed.CommaFeedApplication; import com.commafeed.CommaFeedConfiguration; import com.commafeed.backend.Digests; @@ -29,25 +28,25 @@ 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.auth.SecurityCheck; import com.commafeed.frontend.model.Settings; import com.commafeed.frontend.model.UserModel; -import com.commafeed.frontend.model.request.LoginRequest; import com.commafeed.frontend.model.request.PasswordResetRequest; import com.commafeed.frontend.model.request.ProfileModificationRequest; import com.commafeed.frontend.model.request.RegistrationRequest; -import com.commafeed.frontend.session.SessionHelper; +import com.commafeed.security.AuthenticationContext; +import com.commafeed.security.Roles; import com.google.common.base.Preconditions; -import io.dropwizard.hibernate.UnitOfWork; 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 jakarta.inject.Inject; +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; @@ -56,22 +55,24 @@ 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.Context; 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 lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -@Path("/user") +@Path("/rest/user") +@RolesAllowed(Roles.USER) @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) @Slf4j -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton @Tag(name = "Users") public class UserREST { + private final AuthenticationContext authenticationContext; private final UserDAO userDAO; private final UserRoleDAO userRoleDAO; private final UserSettingsDAO userSettingsDAO; @@ -79,17 +80,19 @@ public class UserREST { private final PasswordEncryptionService encryptionService; private final MailService mailService; private final CommaFeedConfiguration config; + private final UriInfo uri; @Path("/settings") @GET - @UnitOfWork + @Transactional @Operation( summary = "Retrieve user settings", description = "Retrieve user settings", responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Settings.class))) }) - @Timed - public Response getUserSettings(@Parameter(hidden = true) @SecurityCheck User user) { + public Response getUserSettings() { Settings s = new Settings(); + + User user = authenticationContext.getCurrentUser(); UserSettings settings = userSettingsDAO.findByUser(user); if (settings != null) { s.setReadingMode(settings.getReadingMode().name()); @@ -145,12 +148,12 @@ public class UserREST { @Path("/settings") @POST - @UnitOfWork + @Transactional @Operation(summary = "Save user settings", description = "Save user settings") - @Timed - public Response saveUserSettings(@Parameter(hidden = true) @SecurityCheck User user, @Parameter(required = true) Settings 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(); @@ -187,12 +190,13 @@ public class UserREST { @Path("/profile") @GET - @UnitOfWork + @Transactional @Operation( summary = "Retrieve user's profile", responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = UserModel.class))) }) - @Timed - public Response getUserProfile(@Parameter(hidden = true) @SecurityCheck User user) { + public Response getUserProfile() { + User user = authenticationContext.getCurrentUser(); + UserModel userModel = new UserModel(); userModel.setId(user.getId()); userModel.setName(user.getName()); @@ -209,11 +213,10 @@ public class UserREST { @Path("/profile") @POST - @UnitOfWork + @Transactional @Operation(summary = "Save user's profile") - @Timed - public Response saveUserProfile(@Parameter(hidden = true) @SecurityCheck User user, - @Valid @Parameter(required = true) ProfileModificationRequest request) { + 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(); } @@ -242,50 +245,34 @@ public class UserREST { user.setApiKey(userService.generateApiKey(user)); } - userDAO.update(user); + userDAO.saveOrUpdate(user); return Response.ok().build(); } @Path("/register") + @PermitAll @POST - @UnitOfWork + @Transactional @Operation(summary = "Register a new account") - @Timed - public Response registerUser(@Valid @Parameter(required = true) RegistrationRequest req, - @Context @Parameter(hidden = true) SessionHelper sessionHelper) { + public Response registerUser(@Valid @Parameter(required = true) RegistrationRequest req) { try { - User registeredUser = userService.register(req.getName(), req.getPassword(), req.getEmail(), - Collections.singletonList(Role.USER)); - userService.login(req.getName(), req.getPassword()); - sessionHelper.setLoggedInUser(registeredUser); + 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("/login") - @POST - @UnitOfWork - @Operation(summary = "Login and create a session") - @Timed - public Response login(@Valid @Parameter(required = true) LoginRequest req, - @Parameter(hidden = true) @Context SessionHelper sessionHelper) { - Optional user = userService.login(req.getName(), req.getPassword()); - if (user.isPresent()) { - sessionHelper.setLoggedInUser(user.get()); - return Response.ok().build(); - } else { - return Response.status(Response.Status.UNAUTHORIZED).entity("wrong username or password").type(MediaType.TEXT_PLAIN).build(); - } - } - @Path("/passwordReset") + @PermitAll @POST - @UnitOfWork + @Transactional @Operation(summary = "send a password reset email") - @Timed 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(); @@ -304,7 +291,7 @@ public class UserREST { } private String buildEmailContent(User user) throws Exception { - String publicUrl = FeedUtils.removeTrailingSlash(config.getApplicationSettings().getPublicUrl()); + 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.", @@ -320,10 +307,10 @@ public class UserREST { } @Path("/passwordResetCallback") + @PermitAll @GET - @UnitOfWork + @Transactional @Produces(MediaType.TEXT_HTML) - @Timed public Response passwordRecoveryCallback(@Parameter(required = true) @QueryParam("email") String email, @Parameter(required = true) @QueryParam("token") String token) { Preconditions.checkNotNull(email); @@ -352,16 +339,16 @@ public class UserREST { String message = "Your new password is: " + passwd; message += "
"; - message += String.format("Back to Homepage", config.getApplicationSettings().getPublicUrl()); + message += String.format("Back to Homepage", uri.getBaseUri()); return Response.ok(message).build(); } @Path("/profile/deleteAccount") @POST - @UnitOfWork + @Transactional @Operation(summary = "Delete the user account") - @Timed - public Response deleteUser(@Parameter(hidden = true) @SecurityCheck User user) { + 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(); } 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 551f1f8e..69b7ba1a 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 @@ -13,9 +13,9 @@ import java.util.Optional; import java.util.stream.Collectors; import java.util.stream.Stream; -import org.glassfish.jersey.media.multipart.FormDataMultiPart; +import org.jboss.resteasy.reactive.server.multipart.FormValue; +import org.jboss.resteasy.reactive.server.multipart.MultipartFormDataInput; -import com.codahale.metrics.annotation.Timed; import com.commafeed.backend.dao.FeedCategoryDAO; import com.commafeed.backend.dao.FeedEntryDAO; import com.commafeed.backend.dao.FeedEntryStatusDAO; @@ -37,10 +37,10 @@ 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.dropwizard.hibernate.UnitOfWork; import io.swagger.v3.oas.annotations.Hidden; -import jakarta.inject.Inject; +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; @@ -64,9 +64,10 @@ import lombok.RequiredArgsConstructor; * * See https://feedafever.com/api */ -@Path("/fever") +@Path("/rest/fever") +@PermitAll @Produces(MediaType.APPLICATION_JSON) -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@RequiredArgsConstructor @Singleton @Hidden public class FeverREST { @@ -88,8 +89,7 @@ public class FeverREST { @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Path(PATH) @POST - @UnitOfWork - @Timed + @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))); @@ -101,8 +101,7 @@ public class FeverREST { // e.g. FeedMe @Path(PATH) @POST - @UnitOfWork - @Timed + @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))); @@ -113,8 +112,7 @@ public class FeverREST { // e.g. Unread @Path(PATH) @GET - @UnitOfWork - @Timed + @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))); @@ -126,12 +124,11 @@ public class FeverREST { @Consumes(MediaType.MULTIPART_FORM_DATA) @Path(PATH) @POST - @UnitOfWork - @Timed - public FeverResponse formData(@Context UriInfo uri, @PathParam("userId") Long userId, FormDataMultiPart form) { + @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.getFields().forEach((k, v) -> params.put(k, v.get(0).getValue())); + form.getValues().forEach((k, v) -> params.put(k, v.stream().map(FormValue::getValue).findFirst().orElse(null))); return handle(userId, params); } diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/servlet/AbstractCustomCodeServlet.java b/commafeed-server/src/main/java/com/commafeed/frontend/servlet/AbstractCustomCodeServlet.java deleted file mode 100644 index 29df13f8..00000000 --- a/commafeed-server/src/main/java/com/commafeed/frontend/servlet/AbstractCustomCodeServlet.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.commafeed.frontend.servlet; - -import java.io.IOException; -import java.util.Optional; - -import com.commafeed.backend.dao.UnitOfWork; -import com.commafeed.backend.dao.UserDAO; -import com.commafeed.backend.dao.UserSettingsDAO; -import com.commafeed.backend.model.User; -import com.commafeed.backend.model.UserSettings; -import com.commafeed.frontend.session.SessionHelper; - -import jakarta.servlet.http.HttpServlet; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -abstract class AbstractCustomCodeServlet extends HttpServlet { - - private static final long serialVersionUID = 1L; - - private final transient UnitOfWork unitOfWork; - private final transient UserDAO userDAO; - private final transient UserSettingsDAO userSettingsDAO; - - @Override - protected final void doGet(final HttpServletRequest req, HttpServletResponse resp) throws IOException { - resp.setContentType(getMimeType()); - - SessionHelper sessionHelper = new SessionHelper(req); - Optional userId = sessionHelper.getLoggedInUserId(); - final Optional user = unitOfWork.call(() -> userId.map(userDAO::findById)); - if (user.isEmpty()) { - return; - } - - UserSettings settings = unitOfWork.call(() -> userSettingsDAO.findByUser(user.get())); - if (settings == null) { - return; - } - - String customCode = getCustomCode(settings); - if (customCode == null) { - return; - } - - resp.getWriter().write(customCode); - } - - protected abstract String getMimeType(); - - protected abstract String getCustomCode(UserSettings settings); -} 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 7bcc1f91..0f95ee28 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,28 +1,39 @@ package com.commafeed.frontend.servlet; import com.commafeed.backend.dao.UnitOfWork; -import com.commafeed.backend.dao.UserDAO; import com.commafeed.backend.dao.UserSettingsDAO; +import com.commafeed.backend.model.User; import com.commafeed.backend.model.UserSettings; +import com.commafeed.security.AuthenticationContext; -import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import lombok.RequiredArgsConstructor; -public class CustomCssServlet extends AbstractCustomCodeServlet { +@Path("/custom_css.css") +@Produces("text/css") +@RequiredArgsConstructor +@Singleton +public class CustomCssServlet { - private static final long serialVersionUID = 1L; + private final AuthenticationContext authenticationContext; + private final UserSettingsDAO userSettingsDAO; + private final UnitOfWork unitOfWork; - @Inject - public CustomCssServlet(UnitOfWork unitOfWork, UserDAO userDAO, UserSettingsDAO userSettingsDAO) { - super(unitOfWork, userDAO, userSettingsDAO); - } + @GET + public String get() { + User user = authenticationContext.getCurrentUser(); + if (user == null) { + return ""; + } - @Override - protected String getMimeType() { - return "text/css"; - } + UserSettings settings = unitOfWork.call(() -> userSettingsDAO.findByUser(user)); + if (settings == null) { + return ""; + } - @Override - protected String getCustomCode(UserSettings settings) { 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 0c29b7a9..2d651db5 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,30 +1,39 @@ package com.commafeed.frontend.servlet; import com.commafeed.backend.dao.UnitOfWork; -import com.commafeed.backend.dao.UserDAO; import com.commafeed.backend.dao.UserSettingsDAO; +import com.commafeed.backend.model.User; import com.commafeed.backend.model.UserSettings; +import com.commafeed.security.AuthenticationContext; -import jakarta.inject.Inject; import jakarta.inject.Singleton; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import lombok.RequiredArgsConstructor; +@Path("/custom_js.js") +@Produces("application/javascript") +@RequiredArgsConstructor @Singleton -public class CustomJsServlet extends AbstractCustomCodeServlet { +public class CustomJsServlet { - private static final long serialVersionUID = 1L; + private final AuthenticationContext authenticationContext; + private final UserSettingsDAO userSettingsDAO; + private final UnitOfWork unitOfWork; - @Inject - public CustomJsServlet(UnitOfWork unitOfWork, UserDAO userDAO, UserSettingsDAO userSettingsDAO) { - super(unitOfWork, userDAO, userSettingsDAO); - } + @GET + public String get() { + User user = authenticationContext.getCurrentUser(); + if (user == null) { + return ""; + } - @Override - protected String getMimeType() { - return "application/javascript"; - } + UserSettings settings = unitOfWork.call(() -> userSettingsDAO.findByUser(user)); + if (settings == null) { + return ""; + } - @Override - protected String getCustomCode(UserSettings settings) { 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 6d405709..7114de35 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,26 +1,34 @@ package com.commafeed.frontend.servlet; -import java.io.IOException; +import java.time.Instant; +import java.util.Date; -import com.commafeed.CommaFeedConfiguration; +import org.eclipse.microprofile.config.inject.ConfigProperty; -import jakarta.inject.Inject; +import jakarta.annotation.security.PermitAll; import jakarta.inject.Singleton; -import jakarta.servlet.http.HttpServlet; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; +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; -@SuppressWarnings("serial") -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@Path("/logout") +@PermitAll @Singleton -public class LogoutServlet extends HttpServlet { +public class LogoutServlet { - private final CommaFeedConfiguration config; + private final UriInfo uri; + private final String cookieName; - @Override - protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { - req.getSession().invalidate(); - resp.sendRedirect(resp.encodeRedirectURL(config.getApplicationSettings().getPublicUrl())); + 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 dabda582..ae4d3ca9 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,96 +1,77 @@ package com.commafeed.frontend.servlet; -import java.io.IOException; +import java.net.URI; import java.util.List; -import java.util.Optional; 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.dao.UnitOfWork; -import com.commafeed.backend.dao.UserDAO; 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.backend.service.UserService; import com.commafeed.frontend.resource.CategoryREST; -import com.commafeed.frontend.session.SessionHelper; +import com.commafeed.security.AuthenticationContext; import com.google.common.collect.Iterables; -import jakarta.inject.Inject; import jakarta.inject.Singleton; -import jakarta.servlet.http.HttpServlet; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; +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 lombok.RequiredArgsConstructor; -@SuppressWarnings("serial") -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) +@Path("/next") +@RequiredArgsConstructor @Singleton -public class NextUnreadServlet extends HttpServlet { - - private static final String PARAM_CATEGORYID = "category"; - private static final String PARAM_READINGORDER = "order"; +public class NextUnreadServlet { private final UnitOfWork unitOfWork; private final FeedSubscriptionDAO feedSubscriptionDAO; private final FeedEntryStatusDAO feedEntryStatusDAO; private final FeedCategoryDAO feedCategoryDAO; - private final UserDAO userDAO; - private final UserService userService; private final FeedEntryService feedEntryService; - private final CommaFeedConfiguration config; + private final AuthenticationContext authenticationContext; + private final UriInfo uri; - @Override - protected void doGet(final HttpServletRequest req, HttpServletResponse resp) throws IOException { - final String categoryId = req.getParameter(PARAM_CATEGORYID); - String orderParam = req.getParameter(PARAM_READINGORDER); - - SessionHelper sessionHelper = new SessionHelper(req); - Optional userId = sessionHelper.getLoggedInUserId(); - Optional user = unitOfWork.call(() -> userId.map(userDAO::findById)); - user.ifPresent(value -> unitOfWork.run(() -> userService.performPostLoginActivities(value))); - if (user.isEmpty()) { - resp.sendRedirect(resp.encodeRedirectURL(config.getApplicationSettings().getPublicUrl())); - return; + @GET + 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(); } - final ReadingOrder order = StringUtils.equals(orderParam, "asc") ? ReadingOrder.asc : ReadingOrder.desc; - FeedEntryStatus status = unitOfWork.call(() -> { FeedEntryStatus s = null; if (StringUtils.isBlank(categoryId) || CategoryREST.ALL.equals(categoryId)) { - List subs = feedSubscriptionDAO.findAll(user.get()); - List statuses = feedEntryStatusDAO.findBySubscriptions(user.get(), subs, true, null, null, 0, 1, order, - true, null, null, null); + 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.get(), Long.valueOf(categoryId)); + FeedCategory category = feedCategoryDAO.findById(user, Long.valueOf(categoryId)); if (category != null) { - List children = feedCategoryDAO.findAllChildrenCategories(user.get(), category); - List subscriptions = feedSubscriptionDAO.findByCategories(user.get(), children); - List statuses = feedEntryStatusDAO.findBySubscriptions(user.get(), subscriptions, true, null, null, 0, - 1, order, true, null, null, 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.get(), s.getEntry().getId(), true); + feedEntryService.markEntry(user, s.getEntry().getId(), true); } return s; }); - if (status == null) { - resp.sendRedirect(resp.encodeRedirectURL(config.getApplicationSettings().getPublicUrl())); - } else { - String url = status.getEntry().getUrl(); - resp.sendRedirect(resp.encodeRedirectURL(url)); - } + String url = status == null ? uri.getBaseUri().toString() : status.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 b9873ffe..3e29cc6d 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,22 +1,33 @@ package com.commafeed.frontend.servlet; -import java.io.IOException; +import org.apache.hc.core5.http.HttpStatus; +import com.commafeed.CommaFeedConfiguration; + +import jakarta.annotation.security.PermitAll; import jakarta.inject.Singleton; -import jakarta.servlet.http.HttpServlet; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; +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 lombok.RequiredArgsConstructor; +@Path("/robots.txt") +@PermitAll +@Produces(MediaType.TEXT_PLAIN) +@RequiredArgsConstructor @Singleton -public class RobotsTxtDisallowAllServlet extends HttpServlet { +public class RobotsTxtDisallowAllServlet { - private static final long serialVersionUID = 1L; + private final CommaFeedConfiguration config; - @Override - protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { - resp.setContentType("text/plain"); - resp.getWriter().write("User-agent: *"); - resp.getWriter().write("\n"); - resp.getWriter().write("Disallow: /"); + @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/session/SessionHandlerFactory.java b/commafeed-server/src/main/java/com/commafeed/frontend/session/SessionHandlerFactory.java deleted file mode 100644 index a3388db4..00000000 --- a/commafeed-server/src/main/java/com/commafeed/frontend/session/SessionHandlerFactory.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.commafeed.frontend.session; - -import org.eclipse.jetty.server.session.DatabaseAdaptor; -import org.eclipse.jetty.server.session.DefaultSessionCache; -import org.eclipse.jetty.server.session.JDBCSessionDataStore; -import org.eclipse.jetty.server.session.SessionCache; -import org.eclipse.jetty.server.session.SessionHandler; - -import com.codahale.metrics.MetricRegistry; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.common.collect.ImmutableSet; - -import io.dropwizard.db.DataSourceFactory; -import io.dropwizard.util.Duration; -import jakarta.servlet.SessionTrackingMode; - -public class SessionHandlerFactory { - - @JsonProperty - private Duration cookieMaxAge = Duration.days(30); - - @JsonProperty - private Duration cookieRefreshAge = Duration.days(1); - - @JsonProperty - private Duration maxInactiveInterval = Duration.days(30); - - @JsonProperty - private Duration savePeriod = Duration.minutes(5); - - public SessionHandler build(DataSourceFactory dataSourceFactory) { - SessionHandler sessionHandler = new SessionHandler(); - sessionHandler.setHttpOnly(true); - sessionHandler.setSessionTrackingModes(ImmutableSet.of(SessionTrackingMode.COOKIE)); - sessionHandler.setMaxInactiveInterval((int) maxInactiveInterval.toSeconds()); - sessionHandler.setRefreshCookieAge((int) cookieRefreshAge.toSeconds()); - sessionHandler.getSessionCookieConfig().setMaxAge((int) cookieMaxAge.toSeconds()); - - SessionCache sessionCache = new DefaultSessionCache(sessionHandler); - sessionHandler.setSessionCache(sessionCache); - - JDBCSessionDataStore dataStore = new JDBCSessionDataStore(); - dataStore.setSavePeriodSec((int) savePeriod.toSeconds()); - sessionCache.setSessionDataStore(dataStore); - - DatabaseAdaptor adaptor = new DatabaseAdaptor(); - adaptor.setDatasource(dataSourceFactory.build(new MetricRegistry(), "sessions")); - dataStore.setDatabaseAdaptor(adaptor); - - return sessionHandler; - } - -} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/session/SessionHelper.java b/commafeed-server/src/main/java/com/commafeed/frontend/session/SessionHelper.java deleted file mode 100644 index 97341ea3..00000000 --- a/commafeed-server/src/main/java/com/commafeed/frontend/session/SessionHelper.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.commafeed.frontend.session; - -import java.util.Optional; - -import com.commafeed.backend.model.User; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -public class SessionHelper { - - public static final String SESSION_KEY_USER_ID = "user-id"; - - private final HttpServletRequest request; - - public Optional getLoggedInUserId() { - HttpSession session = request.getSession(false); - return getLoggedInUserId(session); - } - - public static Optional getLoggedInUserId(HttpSession session) { - if (session == null) { - return Optional.empty(); - } - Long userId = (Long) session.getAttribute(SESSION_KEY_USER_ID); - return Optional.ofNullable(userId); - } - - public void setLoggedInUser(User user) { - request.getSession(true).setAttribute(SESSION_KEY_USER_ID, user.getId()); - } - -} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/session/SessionHelperFactoryProvider.java b/commafeed-server/src/main/java/com/commafeed/frontend/session/SessionHelperFactoryProvider.java deleted file mode 100644 index 4ff25de6..00000000 --- a/commafeed-server/src/main/java/com/commafeed/frontend/session/SessionHelperFactoryProvider.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.commafeed.frontend.session; - -import java.util.function.Function; - -import org.glassfish.hk2.utilities.binding.AbstractBinder; -import org.glassfish.jersey.server.ContainerRequest; -import org.glassfish.jersey.server.internal.inject.AbstractValueParamProvider; -import org.glassfish.jersey.server.internal.inject.MultivaluedParameterExtractorProvider; -import org.glassfish.jersey.server.model.Parameter; -import org.glassfish.jersey.server.spi.internal.ValueParamProvider; - -import jakarta.inject.Inject; -import jakarta.inject.Singleton; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.ws.rs.core.Context; - -@Singleton -public class SessionHelperFactoryProvider extends AbstractValueParamProvider { - - private final HttpServletRequest request; - - @Inject - public SessionHelperFactoryProvider(final MultivaluedParameterExtractorProvider extractorProvider, HttpServletRequest request) { - super(() -> extractorProvider, Parameter.Source.CONTEXT); - this.request = request; - } - - @Override - protected Function createValueProvider(Parameter parameter) { - final Class classType = parameter.getRawType(); - - Context context = parameter.getAnnotation(Context.class); - if (context == null) { - return null; - } - - if (!classType.isAssignableFrom(SessionHelper.class)) { - return null; - } - - return r -> new SessionHelper(request); - } - - public static class Binder extends AbstractBinder { - - @Override - protected void configure() { - bind(SessionHelperFactoryProvider.class).to(ValueParamProvider.class).in(Singleton.class); - } - } -} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/ws/WebSocketConfigurator.java b/commafeed-server/src/main/java/com/commafeed/frontend/ws/WebSocketConfigurator.java deleted file mode 100644 index 70d6a903..00000000 --- a/commafeed-server/src/main/java/com/commafeed/frontend/ws/WebSocketConfigurator.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.commafeed.frontend.ws; - -import java.util.Optional; - -import com.commafeed.frontend.session.SessionHelper; - -import jakarta.inject.Inject; -import jakarta.inject.Singleton; -import jakarta.servlet.http.HttpSession; -import jakarta.websocket.HandshakeResponse; -import jakarta.websocket.server.HandshakeRequest; -import jakarta.websocket.server.ServerEndpointConfig; -import jakarta.websocket.server.ServerEndpointConfig.Configurator; -import lombok.RequiredArgsConstructor; - -@Singleton -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) -public class WebSocketConfigurator extends Configurator { - - public static final String SESSIONKEY_USERID = "userId"; - - private final WebSocketSessions webSocketSessions; - - @Override - public void modifyHandshake(ServerEndpointConfig config, HandshakeRequest request, HandshakeResponse response) { - HttpSession httpSession = (HttpSession) request.getHttpSession(); - if (httpSession != null) { - Optional userId = SessionHelper.getLoggedInUserId(httpSession); - userId.ifPresent(value -> config.getUserProperties().put(SESSIONKEY_USERID, value)); - } - } - - @SuppressWarnings("unchecked") - @Override - public T getEndpointInstance(Class endpointClass) { - return (T) new WebSocketEndpoint(webSocketSessions); - } -} 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 4d145a75..93202ebf 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 @@ -2,39 +2,54 @@ package com.commafeed.frontend.ws; import java.io.IOException; -import jakarta.inject.Inject; +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.backend.model.User; +import com.commafeed.security.AuthenticationContext; + import jakarta.inject.Singleton; import jakarta.websocket.CloseReason; import jakarta.websocket.CloseReason.CloseCodes; -import jakarta.websocket.Endpoint; -import jakarta.websocket.EndpointConfig; +import jakarta.websocket.OnClose; +import jakarta.websocket.OnMessage; +import jakarta.websocket.OnOpen; import jakarta.websocket.Session; +import jakarta.websocket.server.ServerEndpoint; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j @Singleton -@RequiredArgsConstructor(onConstructor = @__({ @Inject })) -public class WebSocketEndpoint extends Endpoint { +@ServerEndpoint("/ws") +@RequiredArgsConstructor +public class WebSocketEndpoint { + private final AuthenticationContext authenticationContext; + private final CommaFeedConfiguration config; private final WebSocketSessions sessions; - @Override - public void onOpen(Session session, EndpointConfig config) { - Long userId = (Long) config.getUserProperties().get(WebSocketConfigurator.SESSIONKEY_USERID); - if (userId == null) { + @OnOpen + public void onOpen(Session session) { + User user = authenticationContext.getCurrentUser(); + if (user == null) { reject(session); return; } - log.debug("created websocket session for user {}", userId); - sessions.add(userId, session); + log.debug("created websocket session for user '{}'", user.getName()); + sessions.add(user.getId(), session); + session.setMaxIdleTimeout(config.websocket().pingInterval().toMillis() + 10000); + } - session.addMessageHandler(String.class, message -> { - if ("ping".equals(message)) { - session.getAsyncRemote().sendText("pong"); - } - }); + @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) { @@ -45,9 +60,4 @@ public class WebSocketEndpoint extends Endpoint { } } - @Override - public void onClose(Session session, CloseReason reason) { - sessions.remove(session); - } - } 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 e82563e7..93caea5b 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 @@ -8,7 +8,6 @@ import com.codahale.metrics.Gauge; import com.codahale.metrics.MetricRegistry; import com.commafeed.backend.model.User; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import jakarta.websocket.Session; import lombok.extern.slf4j.Slf4j; @@ -20,7 +19,6 @@ public class WebSocketSessions { // a user may have multiple sessions (two tabs, two devices, ...) private final Map> sessions = new ConcurrentHashMap<>(); - @Inject public WebSocketSessions(MetricRegistry metrics) { metrics.register(MetricRegistry.name(getClass(), "users"), (Gauge) () -> sessions.values().stream().filter(v -> !v.isEmpty()).count()); diff --git a/commafeed-server/src/main/java/com/commafeed/security/AuthenticationContext.java b/commafeed-server/src/main/java/com/commafeed/security/AuthenticationContext.java new file mode 100644 index 00000000..e0f5f794 --- /dev/null +++ b/commafeed-server/src/main/java/com/commafeed/security/AuthenticationContext.java @@ -0,0 +1,29 @@ +package com.commafeed.security; + +import com.commafeed.backend.dao.UserDAO; +import com.commafeed.backend.model.User; + +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.inject.Singleton; +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 new file mode 100644 index 00000000..f614ca94 --- /dev/null +++ b/commafeed-server/src/main/java/com/commafeed/security/Roles.java @@ -0,0 +1,6 @@ +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 new file mode 100644 index 00000000..5c1c95e7 --- /dev/null +++ b/commafeed-server/src/main/java/com/commafeed/security/identity/DatabaseApiKeyIdentityProvider.java @@ -0,0 +1,50 @@ +package com.commafeed.security.identity; + +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +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 jakarta.inject.Singleton; +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 new file mode 100644 index 00000000..dc3537da --- /dev/null +++ b/commafeed-server/src/main/java/com/commafeed/security/identity/DatabaseUsernamePasswordIdentityProvider.java @@ -0,0 +1,51 @@ +package com.commafeed.security.identity; + +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +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 jakarta.inject.Singleton; +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 new file mode 100644 index 00000000..ecfffc57 --- /dev/null +++ b/commafeed-server/src/main/java/com/commafeed/security/identity/TrustedIdentityProvider.java @@ -0,0 +1,57 @@ +package com.commafeed.security.identity; + +import java.util.Set; +import java.util.stream.Collectors; + +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 jakarta.inject.Singleton; +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 new file mode 100644 index 00000000..b611f827 --- /dev/null +++ b/commafeed-server/src/main/java/com/commafeed/security/mechanism/ApiKeyAuthenticationMecanism.java @@ -0,0 +1,46 @@ +package com.commafeed.security.mechanism; + +import java.util.Optional; +import java.util.Set; + +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; +import jakarta.inject.Singleton; + +@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/mechanism/CookieMaxAgeFormAuthenticationMechanism.java b/commafeed-server/src/main/java/com/commafeed/security/mechanism/CookieMaxAgeFormAuthenticationMechanism.java new file mode 100644 index 00000000..c5ffa581 --- /dev/null +++ b/commafeed-server/src/main/java/com/commafeed/security/mechanism/CookieMaxAgeFormAuthenticationMechanism.java @@ -0,0 +1,90 @@ +package com.commafeed.security.mechanism; + +import java.security.SecureRandom; +import java.util.Base64; + +import io.quarkus.vertx.http.runtime.FormAuthConfig; +import io.quarkus.vertx.http.runtime.FormAuthRuntimeConfig; +import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; +import io.quarkus.vertx.http.runtime.HttpConfiguration; +import io.quarkus.vertx.http.runtime.security.FormAuthenticationMechanism; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; +import io.quarkus.vertx.http.runtime.security.PersistentLoginManager; +import io.vertx.core.http.Cookie; +import io.vertx.core.http.impl.ServerCookie; +import io.vertx.ext.web.RoutingContext; +import jakarta.annotation.Priority; +import jakarta.inject.Singleton; +import lombok.experimental.Delegate; +import lombok.extern.slf4j.Slf4j; + +/** + * HttpAuthenticationMechanism that wraps FormAuthenticationMechanism and sets a Max-Age on the cookie because it has no value by default. + * + * This is a workaround for https://github.com/quarkusio/quarkus/issues/42463 + */ +@Priority(1) +@Singleton +@Slf4j +public class CookieMaxAgeFormAuthenticationMechanism implements HttpAuthenticationMechanism { + + // the temp encryption key, persistent across dev mode restarts + static volatile String encryptionKey; + + @Delegate + private final FormAuthenticationMechanism delegate; + + public CookieMaxAgeFormAuthenticationMechanism(HttpConfiguration httpConfiguration, HttpBuildTimeConfig buildTimeConfig) { + String key; + if (httpConfiguration.encryptionKey.isEmpty()) { + if (encryptionKey != null) { + // persist across dev mode restarts + key = encryptionKey; + } else { + byte[] data = new byte[32]; + new SecureRandom().nextBytes(data); + key = encryptionKey = Base64.getEncoder().encodeToString(data); + log.warn("Encryption key was not specified for persistent FORM auth, using temporary key {}", key); + } + } else { + key = httpConfiguration.encryptionKey.get(); + } + + FormAuthConfig form = buildTimeConfig.auth.form; + FormAuthRuntimeConfig runtimeForm = httpConfiguration.auth.form; + String loginPage = startWithSlash(runtimeForm.loginPage.orElse(null)); + String errorPage = startWithSlash(runtimeForm.errorPage.orElse(null)); + String landingPage = startWithSlash(runtimeForm.landingPage.orElse(null)); + String postLocation = startWithSlash(form.postLocation); + String usernameParameter = runtimeForm.usernameParameter; + String passwordParameter = runtimeForm.passwordParameter; + String locationCookie = runtimeForm.locationCookie; + String cookiePath = runtimeForm.cookiePath.orElse(null); + boolean redirectAfterLogin = landingPage != null; + String cookieSameSite = runtimeForm.cookieSameSite.name(); + + PersistentLoginManager loginManager = new PersistentLoginManager(key, runtimeForm.cookieName, runtimeForm.timeout.toMillis(), + runtimeForm.newCookieInterval.toMillis(), runtimeForm.httpOnlyCookie, cookieSameSite, cookiePath) { + @Override + public void save(String value, RoutingContext context, String cookieName, RestoreResult restoreResult, boolean secureCookie) { + super.save(value, context, cookieName, restoreResult, secureCookie); + + // add max age to the cookie + Cookie cookie = context.request().getCookie(cookieName); + if (cookie instanceof ServerCookie sc && sc.isChanged()) { + cookie.setMaxAge(runtimeForm.timeout.toSeconds()); + } + } + }; + + this.delegate = new FormAuthenticationMechanism(loginPage, postLocation, usernameParameter, passwordParameter, errorPage, + landingPage, redirectAfterLogin, locationCookie, cookieSameSite, cookiePath, loginManager); + } + + private static String startWithSlash(String page) { + if (page == null) { + return null; + } + return page.startsWith("/") ? page : "/" + page; + } +} diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/auth/PasswordConstraintValidator.java b/commafeed-server/src/main/java/com/commafeed/security/password/PasswordConstraintValidator.java similarity index 94% rename from commafeed-server/src/main/java/com/commafeed/frontend/auth/PasswordConstraintValidator.java rename to commafeed-server/src/main/java/com/commafeed/security/password/PasswordConstraintValidator.java index 5063770f..4b842c77 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/auth/PasswordConstraintValidator.java +++ b/commafeed-server/src/main/java/com/commafeed/security/password/PasswordConstraintValidator.java @@ -1,4 +1,4 @@ -package com.commafeed.frontend.auth; +package com.commafeed.security.password; import java.util.List; diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/auth/ValidPassword.java b/commafeed-server/src/main/java/com/commafeed/security/password/ValidPassword.java similarity index 90% rename from commafeed-server/src/main/java/com/commafeed/frontend/auth/ValidPassword.java rename to commafeed-server/src/main/java/com/commafeed/security/password/ValidPassword.java index 63efdb4d..fc559c50 100644 --- a/commafeed-server/src/main/java/com/commafeed/frontend/auth/ValidPassword.java +++ b/commafeed-server/src/main/java/com/commafeed/security/password/ValidPassword.java @@ -1,4 +1,4 @@ -package com.commafeed.frontend.auth; +package com.commafeed.security.password; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; 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 new file mode 100644 index 00000000..a8a50b21 --- /dev/null +++ b/commafeed-server/src/main/resources/META-INF/native-image/commafeed/resource-config.json @@ -0,0 +1,10 @@ +{ + "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 new file mode 100644 index 00000000..29a9854d --- /dev/null +++ b/commafeed-server/src/main/resources/META-INF/native-image/cssparser/resource-config.json @@ -0,0 +1,7 @@ +{ + "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 new file mode 100644 index 00000000..9da0b50f --- /dev/null +++ b/commafeed-server/src/main/resources/application.properties @@ -0,0 +1,45 @@ +# http +quarkus.http.port=8082 +quarkus.http.test-port=8085 + +# 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.landing-page= +quarkus.http.auth.form.login-page= +quarkus.http.auth.form.error-page= + +# websocket +quarkus.websocket.dispatch-to-worker=true + +# database +quarkus.datasource.db-kind=h2 +quarkus.liquibase.change-log=migrations.xml +quarkus.liquibase.migrate-at-start=true + +# shutdown +quarkus.shutdown.timeout=5s + + +# 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 + + +# 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 + + +# 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 7b60e188..b1713439 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,7 +1,7 @@ + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> 7:6d3ad493d25dd9c50067e804efc9ffcc 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 065ead9f..094de039 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,7 +1,7 @@ + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> 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 3bbe49c6..c735f521 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,6 +1,7 @@ - + @@ -14,7 +15,7 @@ + referencedTableName="FEEDS" referencedColumnNames="id" /> 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 691dbde5..3e5f07d2 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,6 +1,7 @@ - + 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 4920edcf..578c0bc0 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,6 +1,7 @@ - + @@ -31,9 +32,9 @@ + referencedTableName="FEEDENTRIES" referencedColumnNames="id" /> + referencedTableName="USERS" referencedColumnNames="id" /> 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 09ce1137..2a8f58f7 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,6 +1,7 @@ - + 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 df158560..6125c98e 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,6 +1,7 @@ - + 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 1af324a6..6fb75000 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,6 +1,7 @@ - + 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 726e4228..29e7fd95 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,7 +1,7 @@ + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> @@ -26,7 +26,7 @@ 8:39e5a9ff312af90d82f87c88abf1c66d + columnDataType="VARCHAR(4096)" /> 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 782777bc..9d337166 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,7 +1,7 @@ + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> 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 238b9b1b..87835d25 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,7 +1,7 @@ + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> 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 eee22181..c3c1c916 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,7 +1,7 @@ + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> 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 2eaeaa95..52bd6b36 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,7 +1,7 @@ + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> 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 83ce42b0..f2c386a3 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,7 +1,7 @@ + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> 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 6e898e72..3d3d6717 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,7 +1,7 @@ + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> 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 7540a1fe..9c00a813 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,7 +1,7 @@ + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> 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 a52f67d6..0af84537 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,7 +1,7 @@ + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> 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 806095f7..a9d696c1 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,7 +1,7 @@ + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> 9:663bcc7c6df5b832ec2109a3afcff5c6 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 c74b779c..c15ed003 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,7 +1,7 @@ + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> 9:078593b238a4639a97a3cd82f7e5e30d diff --git a/commafeed-server/src/main/resources/banner.txt b/commafeed-server/src/main/resources/default_banner.txt similarity index 100% rename from commafeed-server/src/main/resources/banner.txt rename to commafeed-server/src/main/resources/default_banner.txt diff --git a/commafeed-server/src/main/resources/migrations.xml b/commafeed-server/src/main/resources/migrations.xml index 2d951ed1..eae6c227 100644 --- a/commafeed-server/src/main/resources/migrations.xml +++ b/commafeed-server/src/main/resources/migrations.xml @@ -1,7 +1,7 @@ + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> diff --git a/commafeed-server/src/test/java/com/commafeed/CommaFeedDropwizardAppExtension.java b/commafeed-server/src/test/java/com/commafeed/CommaFeedDropwizardAppExtension.java deleted file mode 100644 index 0cc7a0fb..00000000 --- a/commafeed-server/src/test/java/com/commafeed/CommaFeedDropwizardAppExtension.java +++ /dev/null @@ -1,125 +0,0 @@ -package com.commafeed; - -import java.io.IOException; -import java.io.InputStream; -import java.sql.Connection; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Properties; - -import javax.sql.DataSource; - -import org.mockserver.socket.PortFactory; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.JdbcDatabaseContainer; -import org.testcontainers.containers.MariaDBContainer; -import org.testcontainers.containers.MySQLContainer; -import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.utility.DockerImageName; - -import com.codahale.metrics.MetricRegistry; -import com.commafeed.CommaFeedConfiguration.CacheType; - -import io.dropwizard.testing.ConfigOverride; -import io.dropwizard.testing.ResourceHelpers; -import io.dropwizard.testing.junit5.DropwizardAppExtension; -import redis.clients.jedis.Jedis; -import redis.clients.jedis.JedisPool; - -public class CommaFeedDropwizardAppExtension extends DropwizardAppExtension { - private static final String TEST_DATABASE = System.getenv().getOrDefault("TEST_DATABASE", "h2"); - private static final boolean REDIS_ENABLED = Boolean.parseBoolean(System.getenv().getOrDefault("REDIS", "false")); - - private static final ConfigOverride[] CONFIG_OVERRIDES; - private static final List DROP_ALL_STATEMENTS; - static { - List overrides = new ArrayList<>(); - overrides.add(ConfigOverride.config("server.applicationConnectors[0].port", String.valueOf(PortFactory.findFreePort()))); - - Properties imageNames = readProperties("/docker-images.properties"); - - DatabaseConfiguration config = buildConfiguration(TEST_DATABASE, imageNames.getProperty(TEST_DATABASE)); - JdbcDatabaseContainer container = config.container(); - if (container != null) { - container.withDatabaseName("commafeed"); - container.withEnv("TZ", "UTC"); - container.start(); - - overrides.add(ConfigOverride.config("database.url", container.getJdbcUrl())); - overrides.add(ConfigOverride.config("database.user", container.getUsername())); - overrides.add(ConfigOverride.config("database.password", container.getPassword())); - overrides.add(ConfigOverride.config("database.driverClass", container.getDriverClassName())); - } - - if (REDIS_ENABLED) { - GenericContainer redis = new GenericContainer<>(DockerImageName.parse(imageNames.getProperty("redis"))) - .withExposedPorts(6379); - redis.start(); - - overrides.add(ConfigOverride.config("app.cache", "redis")); - overrides.add(ConfigOverride.config("redis.host", redis.getHost())); - overrides.add(ConfigOverride.config("redis.port", redis.getMappedPort(6379).toString())); - } - - CONFIG_OVERRIDES = overrides.toArray(new ConfigOverride[0]); - DROP_ALL_STATEMENTS = config.dropAllStatements(); - } - - public CommaFeedDropwizardAppExtension() { - super(CommaFeedApplication.class, ResourceHelpers.resourceFilePath("config.test.yml"), CONFIG_OVERRIDES); - } - - private static DatabaseConfiguration buildConfiguration(String databaseName, String imageName) { - if ("postgresql".equals(databaseName)) { - JdbcDatabaseContainer container = new PostgreSQLContainer<>(imageName).withTmpFs(Map.of("/var/lib/postgresql/data", "rw")); - return new DatabaseConfiguration(container, List.of("DROP SCHEMA public CASCADE", "CREATE SCHEMA public")); - } else if ("mysql".equals(databaseName)) { - JdbcDatabaseContainer container = new MySQLContainer<>(imageName).withTmpFs(Map.of("/var/lib/mysql", "rw")); - return new DatabaseConfiguration(container, List.of("DROP DATABASE IF EXISTS commafeed", " CREATE DATABASE commafeed")); - } else if ("mariadb".equals(databaseName)) { - JdbcDatabaseContainer container = new MariaDBContainer<>(imageName).withTmpFs(Map.of("/var/lib/mysql", "rw")); - return new DatabaseConfiguration(container, List.of("DROP DATABASE IF EXISTS commafeed", " CREATE DATABASE commafeed")); - } else { - // h2 - return new DatabaseConfiguration(null, List.of("DROP ALL OBJECTS")); - } - } - - private static Properties readProperties(String path) { - Properties properties = new Properties(); - try (InputStream is = CommaFeedDropwizardAppExtension.class.getResourceAsStream(path)) { - properties.load(is); - } catch (IOException e) { - throw new RuntimeException("could not read resource " + path, e); - } - return properties; - } - - @Override - public void after() { - super.after(); - - // clean database after each test - DataSource dataSource = getConfiguration().getDataSourceFactory().build(new MetricRegistry(), "cleanup"); - try (Connection connection = dataSource.getConnection()) { - for (String statement : DROP_ALL_STATEMENTS) { - connection.prepareStatement(statement).executeUpdate(); - } - } catch (SQLException e) { - throw new RuntimeException("could not cleanup database", e); - } - - // clean redis cache after each test - if (getConfiguration().getApplicationSettings().getCache() == CacheType.REDIS) { - try (JedisPool pool = getConfiguration().getRedisPoolFactory().build(); Jedis jedis = pool.getResource()) { - jedis.flushAll(); - } - } - } - - private record DatabaseConfiguration(JdbcDatabaseContainer container, List dropAllStatements) { - } - -} diff --git a/commafeed-server/src/test/java/com/commafeed/DatabaseReset.java b/commafeed-server/src/test/java/com/commafeed/DatabaseReset.java new file mode 100644 index 00000000..97e43ddd --- /dev/null +++ b/commafeed-server/src/test/java/com/commafeed/DatabaseReset.java @@ -0,0 +1,34 @@ +package com.commafeed; + +import org.kohsuke.MetaInfServices; + +import com.commafeed.backend.service.db.DatabaseStartupService; + +import io.quarkus.liquibase.LiquibaseFactory; +import io.quarkus.test.junit.callback.QuarkusTestBeforeEachCallback; +import io.quarkus.test.junit.callback.QuarkusTestMethodContext; +import jakarta.enterprise.inject.spi.CDI; +import liquibase.Liquibase; +import liquibase.exception.LiquibaseException; + +/** + * Resets database between tests + */ +@MetaInfServices +public class DatabaseReset implements QuarkusTestBeforeEachCallback { + + @SuppressWarnings("deprecation") + @Override + public void beforeEach(QuarkusTestMethodContext context) { + LiquibaseFactory liquibaseFactory = CDI.current().select(LiquibaseFactory.class).get(); + try (Liquibase liquibase = liquibaseFactory.createLiquibase()) { + liquibase.dropAll(); + liquibase.update(liquibaseFactory.createContexts(), liquibaseFactory.createLabels()); + } catch (LiquibaseException e) { + throw new RuntimeException(e); + } + + DatabaseStartupService databaseStartupService = CDI.current().select(DatabaseStartupService.class).get(); + databaseStartupService.populateInitialData(); + } +} diff --git a/commafeed-server/src/test/java/com/commafeed/NativeImageClassesTest.java b/commafeed-server/src/test/java/com/commafeed/NativeImageClassesTest.java new file mode 100644 index 00000000..4c961d02 --- /dev/null +++ b/commafeed-server/src/test/java/com/commafeed/NativeImageClassesTest.java @@ -0,0 +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); + } + +} \ 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 06da263d..d2833a8e 100644 --- a/commafeed-server/src/test/java/com/commafeed/backend/HttpGetterTest.java +++ b/commafeed-server/src/test/java/com/commafeed/backend/HttpGetterTest.java @@ -1,14 +1,17 @@ package com.commafeed.backend; import java.io.IOException; +import java.math.BigInteger; import java.net.SocketTimeoutException; +import java.time.Duration; import java.util.Arrays; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; import org.apache.commons.io.IOUtils; import org.apache.hc.client5.http.ConnectTimeoutException; -import org.eclipse.jetty.http.HttpStatus; +import org.apache.hc.core5.http.HttpStatus; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -26,22 +29,22 @@ import org.mockserver.model.MediaType; import com.codahale.metrics.MetricRegistry; import com.commafeed.CommaFeedConfiguration; -import com.commafeed.CommaFeedConfiguration.ApplicationSettings; +import com.commafeed.CommaFeedVersion; import com.commafeed.backend.HttpGetter.HttpResponseException; import com.commafeed.backend.HttpGetter.HttpResult; import com.commafeed.backend.HttpGetter.NotModifiedException; import com.google.common.net.HttpHeaders; -import io.dropwizard.util.DataSize; +import io.quarkus.runtime.configuration.MemorySize; @ExtendWith(MockServerExtension.class) class HttpGetterTest { - private static final int TIMEOUT = 10000; - private MockServerClient mockServerClient; private String feedUrl; private byte[] feedContent; + private CommaFeedConfiguration config; + private HttpGetter getter; @BeforeEach @@ -51,25 +54,26 @@ class HttpGetterTest { this.feedUrl = "http://localhost:" + this.mockServerClient.getPort() + "/"; this.feedContent = IOUtils.toByteArray(Objects.requireNonNull(getClass().getResource("/feed/rss.xml"))); - ApplicationSettings settings = new ApplicationSettings(); - settings.setUserAgent("http-getter-test"); - settings.setBackgroundThreads(3); - settings.setMaxFeedResponseSize(DataSize.kilobytes(10)); + 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.ofMillis(500)); + Mockito.when(config.httpClient().sslHandshakeTimeout()).thenReturn(Duration.ofSeconds(5)); + Mockito.when(config.httpClient().socketTimeout()).thenReturn(Duration.ofMillis(500)); + Mockito.when(config.httpClient().responseTimeout()).thenReturn(Duration.ofMillis(300)); + Mockito.when(config.httpClient().connectionTimeToLive()).thenReturn(Duration.ofSeconds(30)); + Mockito.when(config.httpClient().maxResponseSize()).thenReturn(new MemorySize(new BigInteger("10000"))); + Mockito.when(config.feedRefresh().httpThreads()).thenReturn(3); - CommaFeedConfiguration config = new CommaFeedConfiguration(); - config.setApplicationSettings(settings); - - this.getter = new HttpGetter(config, Mockito.mock(MetricRegistry.class)); + this.getter = new HttpGetter(config, Mockito.mock(CommaFeedVersion.class), Mockito.mock(MetricRegistry.class)); } @ParameterizedTest @ValueSource( - ints = { HttpStatus.UNAUTHORIZED_401, HttpStatus.FORBIDDEN_403, HttpStatus.NOT_FOUND_404, - HttpStatus.INTERNAL_SERVER_ERROR_500 }) + 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.getBinary(this.feedUrl, TIMEOUT)); + HttpResponseException e = Assertions.assertThrows(HttpResponseException.class, () -> getter.getBinary(this.feedUrl)); Assertions.assertEquals(code, e.getCode()); } @@ -82,19 +86,18 @@ class HttpGetterTest { .withHeader(HttpHeaders.LAST_MODIFIED, "123456") .withHeader(HttpHeaders.ETAG, "78910")); - HttpResult result = getter.getBinary(this.feedUrl, TIMEOUT); + HttpResult result = getter.getBinary(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.assertTrue(result.getDuration() > 0); Assertions.assertEquals(this.feedUrl, result.getUrlAfterRedirect()); } @ParameterizedTest @ValueSource( - ints = { HttpStatus.MOVED_PERMANENTLY_301, HttpStatus.MOVED_TEMPORARILY_302, HttpStatus.TEMPORARY_REDIRECT_307, - HttpStatus.PERMANENT_REDIRECT_308 }) + 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("/")) @@ -112,24 +115,23 @@ class HttpGetterTest { this.mockServerClient.when(HttpRequest.request().withMethod("GET").withPath("/redirected-2")) .respond(HttpResponse.response().withBody(feedContent).withContentType(MediaType.APPLICATION_ATOM_XML)); - HttpResult result = getter.getBinary(this.feedUrl, TIMEOUT); + HttpResult result = getter.getBinary(this.feedUrl); Assertions.assertEquals("http://localhost:" + this.mockServerClient.getPort() + "/redirected-2", result.getUrlAfterRedirect()); } @Test void dataTimeout() { - int smallTimeout = 500; this.mockServerClient.when(HttpRequest.request().withMethod("GET")) - .respond(HttpResponse.response().withDelay(Delay.milliseconds(smallTimeout * 2))); + .respond(HttpResponse.response().withDelay(Delay.milliseconds(1000))); - Assertions.assertThrows(SocketTimeoutException.class, () -> getter.getBinary(this.feedUrl, smallTimeout)); + Assertions.assertThrows(SocketTimeoutException.class, () -> getter.getBinary(this.feedUrl)); } @Test void connectTimeout() { // try to connect to a non-routable address // https://stackoverflow.com/a/904609 - Assertions.assertThrows(ConnectTimeoutException.class, () -> getter.getBinary("http://10.255.255.1", 2000)); + Assertions.assertThrows(ConnectTimeoutException.class, () -> getter.getBinary("http://10.255.255.1")); } @Test @@ -137,24 +139,24 @@ class HttpGetterTest { this.mockServerClient.when(HttpRequest.request().withMethod("GET").withHeader(HttpHeaders.USER_AGENT, "http-getter-test")) .respond(HttpResponse.response().withBody("ok")); - HttpResult result = getter.getBinary(this.feedUrl, TIMEOUT); + HttpResult result = getter.getBinary(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.NOT_MODIFIED_304)); + .respond(HttpResponse.response().withStatusCode(HttpStatus.SC_NOT_MODIFIED)); - Assertions.assertThrows(NotModifiedException.class, () -> getter.getBinary(this.feedUrl, "123456", null, TIMEOUT)); + Assertions.assertThrows(NotModifiedException.class, () -> getter.getBinary(this.feedUrl, "123456", null)); } @Test void eTagReturns304() { this.mockServerClient.when(HttpRequest.request().withMethod("GET").withHeader(HttpHeaders.IF_NONE_MATCH, "78910")) - .respond(HttpResponse.response().withStatusCode(HttpStatus.NOT_MODIFIED_304)); + .respond(HttpResponse.response().withStatusCode(HttpStatus.SC_NOT_MODIFIED)); - Assertions.assertThrows(NotModifiedException.class, () -> getter.getBinary(this.feedUrl, null, "78910", TIMEOUT)); + Assertions.assertThrows(NotModifiedException.class, () -> getter.getBinary(this.feedUrl, null, "78910")); } @Test @@ -171,8 +173,8 @@ class HttpGetterTest { return HttpResponse.response().withBody("ok").withHeader(HttpHeaders.SET_COOKIE, "foo=bar"); }); - Assertions.assertDoesNotThrow(() -> getter.getBinary(this.feedUrl, TIMEOUT)); - Assertions.assertDoesNotThrow(() -> getter.getBinary(this.feedUrl, TIMEOUT)); + Assertions.assertDoesNotThrow(() -> getter.getBinary(this.feedUrl)); + Assertions.assertDoesNotThrow(() -> getter.getBinary(this.feedUrl)); Assertions.assertEquals(2, calls.get()); } @@ -190,29 +192,29 @@ class HttpGetterTest { return HttpResponse.response().withBody("ok"); }); - Assertions.assertDoesNotThrow(() -> getter.getBinary(this.feedUrl, TIMEOUT)); + Assertions.assertDoesNotThrow(() -> getter.getBinary(this.feedUrl)); } @Test void largeFeedWithContentLengthHeader() { - byte[] bytes = new byte[(int) DataSize.kilobytes(100).toBytes()]; + 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.getBinary(this.feedUrl, TIMEOUT)); + IOException e = Assertions.assertThrows(IOException.class, () -> getter.getBinary(this.feedUrl)); Assertions.assertEquals("Response size (100000 bytes) exceeds the maximum allowed size (10000 bytes)", e.getMessage()); } @Test void largeFeedWithoutContentLengthHeader() { - byte[] bytes = new byte[(int) DataSize.kilobytes(100).toBytes()]; + 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.getBinary(this.feedUrl, TIMEOUT)); + IOException e = Assertions.assertThrows(IOException.class, () -> getter.getBinary(this.feedUrl)); Assertions.assertEquals("Response size exceeds the maximum allowed size (10000 bytes)", e.getMessage()); } @@ -220,7 +222,7 @@ class HttpGetterTest { void ignoreInvalidSsl() throws Exception { this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(HttpResponse.response().withBody("ok")); - HttpResult result = getter.getBinary("https://localhost:" + this.mockServerClient.getPort(), TIMEOUT); + HttpResult result = getter.getBinary("https://localhost:" + this.mockServerClient.getPort()); Assertions.assertEquals("ok", new String(result.getContent())); } 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 b47ed808..089f6e3c 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,7 +1,7 @@ package com.commafeed.backend.feed; import java.time.Instant; -import java.util.Set; +import java.util.List; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -28,7 +28,7 @@ class FeedFetcherTest { private HttpGetter getter; @Mock - private Set urlProviders; + private List urlProviders; private FeedFetcher fetcher; @@ -45,8 +45,8 @@ class FeedFetcherTest { byte[] content = "content".getBytes(); String lastContentHash = Hashing.sha1().hashBytes(content).toString(); - Mockito.when(getter.getBinary(url, lastModified, etag, 20000)) - .thenReturn(new HttpResult(content, "content-type", "last-modified-2", "etag-2", 20, null)); + Mockito.when(getter.getBinary(url, lastModified, etag)) + .thenReturn(new HttpResult(content, "content-type", "last-modified-2", "etag-2", null)); NotModifiedException e = Assertions.assertThrows(NotModifiedException.class, () -> fetcher.fetch(url, false, lastModified, etag, Instant.now(), lastContentHash)); 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 ae89f32e..08cd6f90 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 @@ -7,7 +7,6 @@ import org.apache.commons.io.IOUtils; import org.junit.jupiter.api.Test; import org.mockito.Mockito; -import com.commafeed.backend.cache.CacheService; import com.commafeed.backend.dao.FeedCategoryDAO; import com.commafeed.backend.model.FeedCategory; import com.commafeed.backend.model.User; @@ -39,12 +38,11 @@ class OPMLImporterTest { private void testOpmlVersion(String fileName) throws IOException, IllegalArgumentException, FeedException { FeedCategoryDAO feedCategoryDAO = Mockito.mock(FeedCategoryDAO.class); FeedSubscriptionService feedSubscriptionService = Mockito.mock(FeedSubscriptionService.class); - CacheService cacheService = Mockito.mock(CacheService.class); User user = Mockito.mock(User.class); String xml = IOUtils.toString(getClass().getResourceAsStream(fileName), StandardCharsets.UTF_8); - OPMLImporter importer = new OPMLImporter(feedCategoryDAO, feedSubscriptionService, cacheService); + OPMLImporter importer = new OPMLImporter(feedCategoryDAO, feedSubscriptionService); importer.importOpml(user, xml); Mockito.verify(feedSubscriptionService) 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 new file mode 100644 index 00000000..3a23b95b --- /dev/null +++ b/commafeed-server/src/test/java/com/commafeed/backend/service/FeedEntryContentCleaningServiceTest.java @@ -0,0 +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()); + } + +} \ 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 5987dd07..34b1dd11 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,9 +1,13 @@ 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; @@ -16,7 +20,10 @@ class FeedEntryFilteringServiceTest { @BeforeEach public void init() { - service = new FeedEntryFilteringService(); + CommaFeedConfiguration config = Mockito.mock(CommaFeedConfiguration.class, Mockito.RETURNS_DEEP_STUBS); + Mockito.when(config.feedRefresh().filteringExpressionEvaluationTimeout()).thenReturn(Duration.ofSeconds(2)); + + service = new FeedEntryFilteringService(config); entry = new FeedEntry(); entry.setUrl("https://github.com/Athou/commafeed"); diff --git a/commafeed-server/src/test/java/com/commafeed/backend/service/db/H2MigrationServiceTest.java b/commafeed-server/src/test/java/com/commafeed/backend/service/db/H2MigrationServiceTest.java deleted file mode 100644 index 578cbc3e..00000000 --- a/commafeed-server/src/test/java/com/commafeed/backend/service/db/H2MigrationServiceTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.commafeed.backend.service.db; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Objects; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class H2MigrationServiceTest { - - @TempDir - private Path root; - - @Test - void testMigrateIfNeeded() throws IOException { - Path path = root.resolve("database.mv.db"); - Files.copy(Objects.requireNonNull(getClass().getResourceAsStream("/h2-migration/database-v2.1.214.mv.db")), path); - - H2MigrationService service = new H2MigrationService(); - Assertions.assertEquals(2, service.getH2FileFormat(path)); - - service.migrateIfNeeded(path, "sa", "sa"); - Assertions.assertEquals(3, service.getH2FileFormat(path)); - } - -} \ 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 56f12089..2fcfbbf6 100644 --- a/commafeed-server/src/test/java/com/commafeed/e2e/AuthentificationIT.java +++ b/commafeed-server/src/test/java/com/commafeed/e2e/AuthentificationIT.java @@ -1,20 +1,35 @@ package com.commafeed.e2e; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import com.commafeed.CommaFeedDropwizardAppExtension; +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.dropwizard.testing.junit5.DropwizardExtensionsSupport; +import io.quarkus.test.junit.QuarkusTest; -@ExtendWith(DropwizardExtensionsSupport.class) -class AuthentificationIT extends PlaywrightTestBase { +@QuarkusTest +class AuthentificationIT { - private static final CommaFeedDropwizardAppExtension EXT = new CommaFeedDropwizardAppExtension(); + 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() { @@ -29,7 +44,7 @@ class AuthentificationIT extends PlaywrightTestBase { void loginSuccess() { page.navigate(getLoginPageUrl()); PlaywrightTestUtils.login(page); - PlaywrightAssertions.assertThat(page).hasURL("http://localhost:" + EXT.getLocalPort() + "/#/app/category/all"); + PlaywrightAssertions.assertThat(page).hasURL("http://localhost:8085/#/app/category/all"); } @Test @@ -56,10 +71,10 @@ class AuthentificationIT extends PlaywrightTestBase { 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:" + EXT.getLocalPort() + "/#/app/category/all"); + PlaywrightAssertions.assertThat(page).hasURL("http://localhost:8085/#/app/category/all"); } private String getLoginPageUrl() { - return "http://localhost:" + EXT.getLocalPort() + "/#/login"; + return "http://localhost:8085/#/login"; } } diff --git a/commafeed-server/src/test/java/com/commafeed/e2e/PlaywrightTestBase.java b/commafeed-server/src/test/java/com/commafeed/e2e/PlaywrightTestBase.java deleted file mode 100644 index 81735c3b..00000000 --- a/commafeed-server/src/test/java/com/commafeed/e2e/PlaywrightTestBase.java +++ /dev/null @@ -1,140 +0,0 @@ -package com.commafeed.e2e; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.util.Optional; - -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.extension.BeforeEachCallback; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.extension.ExtensionContext; -import org.junit.jupiter.api.extension.TestWatcher; - -import com.microsoft.playwright.Browser; -import com.microsoft.playwright.Browser.NewContextOptions; -import com.microsoft.playwright.BrowserContext; -import com.microsoft.playwright.Page; -import com.microsoft.playwright.Playwright; -import com.microsoft.playwright.Tracing; - -/** - * Base class for all Playwright tests. - * - *
    - *
  • Takes a screenshot on failure
  • - *
  • Keeps the video on failure
  • - *
  • Saves a trace file on failure
  • - *
- * - * inspired by https://github.com/microsoft/playwright-java/issues/503#issuecomment-872636373 - * - */ -@ExtendWith(PlaywrightTestBase.SaveArtifactsOnTestFailed.class) -public abstract class PlaywrightTestBase { - - private static Playwright playwright; - private static Browser browser; - - protected Page page; - private BrowserContext context; - - @BeforeAll - static void initBrowser() { - playwright = Playwright.create(); - browser = playwright.chromium().launch(); - } - - @AfterAll - static void closeBrowser() { - playwright.close(); - } - - protected void customizeNewContextOptions(NewContextOptions options) { - // override in subclasses to customize the browser context - } - - protected static class SaveArtifactsOnTestFailed implements TestWatcher, BeforeEachCallback { - - // defined in the config of maven-failsafe-plugin in pom.xml - private final String buildDirectory = System.getProperty("buildDirectory", "target"); - private final String directory = buildDirectory + "/playwright-artifacts"; - - @Override - public void beforeEach(ExtensionContext context) { - PlaywrightTestBase testInstance = getTestInstance(context); - - NewContextOptions newContextOptions = new Browser.NewContextOptions().setRecordVideoDir(Paths.get(directory)); - testInstance.customizeNewContextOptions(newContextOptions); - testInstance.context = PlaywrightTestBase.browser.newContext(newContextOptions); - testInstance.context.tracing().start(new Tracing.StartOptions().setScreenshots(true).setSnapshots(true)); - - testInstance.page = testInstance.context.newPage(); - } - - @Override - public void testFailed(ExtensionContext context, Throwable cause) { - PlaywrightTestBase testInstance = getTestInstance(context); - - String fileName = getFileName(context); - - saveScreenshot(testInstance, fileName); - saveTrace(testInstance, fileName); - - testInstance.context.close(); - - saveVideo(testInstance, fileName); - } - - @Override - public void testAborted(ExtensionContext context, Throwable cause) { - PlaywrightTestBase testInstance = getTestInstance(context); - testInstance.context.close(); - testInstance.page.video().delete(); - } - - @Override - public void testDisabled(ExtensionContext context, Optional reason) { - PlaywrightTestBase testInstance = getTestInstance(context); - testInstance.context.close(); - testInstance.page.video().delete(); - } - - @Override - public void testSuccessful(ExtensionContext context) { - PlaywrightTestBase testInstance = getTestInstance(context); - testInstance.context.close(); - testInstance.page.video().delete(); - } - - private PlaywrightTestBase getTestInstance(ExtensionContext context) { - return (PlaywrightTestBase) context.getRequiredTestInstance(); - } - - private String getFileName(ExtensionContext context) { - return String.format("%s.%s-%s", context.getRequiredTestClass().getSimpleName(), context.getRequiredTestMethod().getName(), - DateTimeFormatter.ofPattern("yyyy-MM-dd--HH-mm-ss").format(ZonedDateTime.now())); - } - - private void saveScreenshot(PlaywrightTestBase testInstance, String fileName) { - byte[] screenshot = testInstance.page.screenshot(); - try { - Files.write(Paths.get(directory, fileName + ".png"), screenshot); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - private void saveTrace(PlaywrightTestBase testInstance, String fileName) { - testInstance.context.tracing().stop(new Tracing.StopOptions().setPath(Paths.get(directory, fileName + ".zip"))); - } - - private void saveVideo(PlaywrightTestBase testInstance, String fileName) { - testInstance.page.video().saveAs(Paths.get(directory, fileName + ".webm")); - testInstance.page.video().delete(); - } - } -} 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 2264701f..fa99d241 100644 --- a/commafeed-server/src/test/java/com/commafeed/e2e/ReadingIT.java +++ b/commafeed-server/src/test/java/com/commafeed/e2e/ReadingIT.java @@ -6,43 +6,51 @@ import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.mockserver.client.MockServerClient; -import org.mockserver.junit.jupiter.MockServerExtension; +import org.mockserver.integration.ClientAndServer; import org.mockserver.model.HttpRequest; import org.mockserver.model.HttpResponse; -import com.commafeed.CommaFeedDropwizardAppExtension; +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.dropwizard.testing.junit5.DropwizardExtensionsSupport; +import io.quarkus.test.junit.QuarkusTest; -@ExtendWith(DropwizardExtensionsSupport.class) -@ExtendWith(MockServerExtension.class) -class ReadingIT extends PlaywrightTestBase { +@QuarkusTest +class ReadingIT { - private static final CommaFeedDropwizardAppExtension EXT = new CommaFeedDropwizardAppExtension(); + private final Playwright playwright = Playwright.create(); + private final Browser browser = playwright.chromium().launch(); + private Page page; private MockServerClient mockServerClient; @BeforeEach - void init(MockServerClient mockServerClient) throws IOException { - this.mockServerClient = mockServerClient; + 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)); } + @AfterEach + void cleanup() { + playwright.close(); + } + @Test void scenario() { // login - page.navigate("http://localhost:" + EXT.getLocalPort()); + page.navigate("http://localhost:8085"); page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Log in")).click(); PlaywrightTestUtils.login(page); diff --git a/commafeed-server/src/test/java/com/commafeed/frontend/auth/SecurityCheckFactoryTest.java b/commafeed-server/src/test/java/com/commafeed/frontend/auth/SecurityCheckFactoryTest.java deleted file mode 100644 index 1069ef8f..00000000 --- a/commafeed-server/src/test/java/com/commafeed/frontend/auth/SecurityCheckFactoryTest.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.commafeed.frontend.auth; - -import java.util.Optional; - -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import com.commafeed.backend.dao.UserDAO; -import com.commafeed.backend.model.User; -import com.commafeed.backend.service.UserService; -import com.commafeed.backend.service.internal.PostLoginActivities; -import com.commafeed.frontend.session.SessionHelper; - -class SecurityCheckFactoryTest { - - @Test - void cookieLoginShouldPerformPostLoginActivities() { - User userInSession = new User(); - UserDAO userDAO = Mockito.mock(UserDAO.class); - Mockito.when(userDAO.findById(1L)).thenReturn(userInSession); - - SessionHelper sessionHelper = Mockito.mock(SessionHelper.class); - Mockito.when(sessionHelper.getLoggedInUserId()).thenReturn(Optional.of(1L)); - - PostLoginActivities postLoginActivities = Mockito.mock(PostLoginActivities.class); - - UserService service = new UserService(null, null, null, null, null, null, null, postLoginActivities); - - SecurityCheckFactory factory = new SecurityCheckFactory(userDAO, service, null, null, null, false); - factory.cookieSessionLogin(sessionHelper); - - Mockito.verify(postLoginActivities).executeFor(userInSession); - } - -} diff --git a/commafeed-server/src/test/java/com/commafeed/frontend/resource/UserRestTest.java b/commafeed-server/src/test/java/com/commafeed/frontend/resource/UserRestTest.java deleted file mode 100644 index a1ccdb4d..00000000 --- a/commafeed-server/src/test/java/com/commafeed/frontend/resource/UserRestTest.java +++ /dev/null @@ -1,107 +0,0 @@ -package com.commafeed.frontend.resource; - -import java.util.Collections; -import java.util.Optional; - -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentMatchers; -import org.mockito.InOrder; -import org.mockito.Mockito; - -import com.commafeed.backend.model.User; -import com.commafeed.backend.model.UserRole.Role; -import com.commafeed.backend.service.UserService; -import com.commafeed.frontend.model.request.LoginRequest; -import com.commafeed.frontend.model.request.RegistrationRequest; -import com.commafeed.frontend.session.SessionHelper; - -class UserRestTest { - - @Test - void loginShouldNotPopulateHttpSessionIfUnsuccessfull() { - // Absent user - Optional absentUser = Optional.empty(); - - // Create UserService partial mock - UserService service = Mockito.mock(UserService.class); - Mockito.when(service.login("user", "password")).thenReturn(absentUser); - - UserREST userREST = new UserREST(null, null, null, service, null, null, null); - SessionHelper sessionHelper = Mockito.mock(SessionHelper.class); - - LoginRequest req = new LoginRequest(); - req.setName("user"); - req.setPassword("password"); - - userREST.login(req, sessionHelper); - - Mockito.verify(sessionHelper, Mockito.never()).setLoggedInUser(Mockito.any(User.class)); - } - - @Test - void loginShouldPopulateHttpSessionIfSuccessfull() { - // Create a user - User user = new User(); - - // Create UserService mock - UserService service = Mockito.mock(UserService.class); - Mockito.when(service.login("user", "password")).thenReturn(Optional.of(user)); - - LoginRequest req = new LoginRequest(); - req.setName("user"); - req.setPassword("password"); - - UserREST userREST = new UserREST(null, null, null, service, null, null, null); - SessionHelper sessionHelper = Mockito.mock(SessionHelper.class); - - userREST.login(req, sessionHelper); - - Mockito.verify(sessionHelper).setLoggedInUser(user); - } - - @Test - void registerShouldRegisterAndThenLogin() { - // Create UserService mock - UserService service = Mockito.mock(UserService.class); - - RegistrationRequest req = new RegistrationRequest(); - req.setName("user"); - req.setPassword("password"); - req.setEmail("test@test.com"); - - InOrder inOrder = Mockito.inOrder(service); - - SessionHelper sessionHelper = Mockito.mock(SessionHelper.class); - UserREST userREST = new UserREST(null, null, null, service, null, null, null); - - userREST.registerUser(req, sessionHelper); - - inOrder.verify(service).register("user", "password", "test@test.com", Collections.singletonList(Role.USER)); - inOrder.verify(service).login("user", "password"); - } - - @Test - void registerShouldPopulateHttpSession() { - // Create a user - User user = new User(); - - // Create UserService mock - UserService service = Mockito.mock(UserService.class); - Mockito.when(service.register(Mockito.any(String.class), Mockito.any(String.class), Mockito.any(String.class), - ArgumentMatchers.anyList())).thenReturn(user); - Mockito.when(service.login(Mockito.any(String.class), Mockito.any(String.class))).thenReturn(Optional.of(user)); - - RegistrationRequest req = new RegistrationRequest(); - req.setName("user"); - req.setPassword("password"); - req.setEmail("test@test.com"); - - SessionHelper sessionHelper = Mockito.mock(SessionHelper.class); - UserREST userREST = new UserREST(null, null, null, service, null, null, null); - - userREST.registerUser(req, sessionHelper); - - Mockito.verify(sessionHelper).setLoggedInUser(user); - } - -} diff --git a/commafeed-server/src/test/java/com/commafeed/frontend/session/SessionHelperTest.java b/commafeed-server/src/test/java/com/commafeed/frontend/session/SessionHelperTest.java deleted file mode 100644 index 6f535879..00000000 --- a/commafeed-server/src/test/java/com/commafeed/frontend/session/SessionHelperTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.commafeed.frontend.session; - -import java.util.Optional; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; - -class SessionHelperTest { - - @Test - void gettingUserDoesNotCreateSession() { - HttpServletRequest request = Mockito.mock(HttpServletRequest.class); - - SessionHelper sessionHelper = new SessionHelper(request); - sessionHelper.getLoggedInUserId(); - - Mockito.verify(request).getSession(false); - } - - @Test - void gettingUserShouldNotReturnUserIfThereIsNoPreexistingHttpSession() { - HttpServletRequest request = Mockito.mock(HttpServletRequest.class); - Mockito.when(request.getSession(false)).thenReturn(null); - - SessionHelper sessionHelper = new SessionHelper(request); - Optional userId = sessionHelper.getLoggedInUserId(); - - Assertions.assertFalse(userId.isPresent()); - } - - @Test - void gettingUserShouldNotReturnUserIfUserNotPresentInHttpSession() { - HttpSession session = Mockito.mock(HttpSession.class); - Mockito.when(session.getAttribute(SessionHelper.SESSION_KEY_USER_ID)).thenReturn(null); - - HttpServletRequest request = Mockito.mock(HttpServletRequest.class); - Mockito.when(request.getSession(false)).thenReturn(session); - - SessionHelper sessionHelper = new SessionHelper(request); - Optional userId = sessionHelper.getLoggedInUserId(); - - Assertions.assertFalse(userId.isPresent()); - } - - @Test - void gettingUserShouldReturnUserIfUserPresentInHttpSession() { - HttpSession session = Mockito.mock(HttpSession.class); - Mockito.when(session.getAttribute(SessionHelper.SESSION_KEY_USER_ID)).thenReturn(1L); - - HttpServletRequest request = Mockito.mock(HttpServletRequest.class); - Mockito.when(request.getSession(false)).thenReturn(session); - - SessionHelper sessionHelper = new SessionHelper(request); - Optional userId = sessionHelper.getLoggedInUserId(); - - Assertions.assertTrue(userId.isPresent()); - } - -} 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 a6f3ad68..00ebe118 100644 --- a/commafeed-server/src/test/java/com/commafeed/integration/BaseIT.java +++ b/commafeed-server/src/test/java/com/commafeed/integration/BaseIT.java @@ -1,84 +1,69 @@ 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 org.apache.commons.io.IOUtils; +import org.apache.hc.core5.http.HttpStatus; import org.awaitility.Awaitility; -import org.eclipse.jetty.http.HttpStatus; -import org.glassfish.jersey.client.JerseyClientBuilder; -import org.glassfish.jersey.media.multipart.MultiPartFeature; import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.extension.ExtendWith; import org.mockserver.client.MockServerClient; -import org.mockserver.junit.jupiter.MockServerExtension; +import org.mockserver.integration.ClientAndServer; import org.mockserver.model.HttpRequest; import org.mockserver.model.HttpResponse; -import com.commafeed.CommaFeedDropwizardAppExtension; import com.commafeed.frontend.model.Entries; import com.commafeed.frontend.model.Subscription; -import com.commafeed.frontend.model.request.LoginRequest; import com.commafeed.frontend.model.request.SubscribeRequest; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; +import io.restassured.RestAssured; +import io.restassured.http.Header; import jakarta.ws.rs.client.Client; -import jakarta.ws.rs.client.Entity; -import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; import lombok.Getter; @Getter -@ExtendWith(DropwizardExtensionsSupport.class) -@ExtendWith(MockServerExtension.class) public abstract class BaseIT { private static final HttpRequest FEED_REQUEST = HttpRequest.request().withMethod("GET").withPath("/"); - private final CommaFeedDropwizardAppExtension extension = new CommaFeedDropwizardAppExtension() { - @Override - protected JerseyClientBuilder clientBuilder() { - return configureClientBuilder(super.clientBuilder().register(MultiPartFeature.class)); - } - }; - + private MockServerClient mockServerClient; private Client client; - private String feedUrl; - private String baseUrl; - private String apiBaseUrl; - private String webSocketUrl; - private MockServerClient mockServerClient; - - protected JerseyClientBuilder configureClientBuilder(JerseyClientBuilder base) { - return base; - } - @BeforeEach - void init(MockServerClient mockServerClient) throws IOException { - this.mockServerClient = mockServerClient; + 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")); - mockServerClient.when(FEED_REQUEST).respond(HttpResponse.response().withBody(IOUtils.toString(resource, StandardCharsets.UTF_8))); - - this.client = extension.client(); - this.feedUrl = "http://localhost:" + mockServerClient.getPort() + "/"; - this.baseUrl = "http://localhost:" + extension.getLocalPort() + "/"; - this.apiBaseUrl = this.baseUrl + "rest/"; - this.webSocketUrl = "ws://localhost:" + extension.getLocalPort() + "/ws"; + this.mockServerClient.when(FEED_REQUEST) + .respond(HttpResponse.response().withBody(IOUtils.toString(resource, StandardCharsets.UTF_8))); } @AfterEach void cleanup() { - this.client.close(); + if (this.mockServerClient != null) { + this.mockServerClient.close(); + } + + if (this.client != null) { + this.client.close(); + } } protected void feedNowReturnsMoreEntries() throws IOException { @@ -88,21 +73,32 @@ public abstract class BaseIT { mockServerClient.when(FEED_REQUEST).respond(HttpResponse.response().withBody(IOUtils.toString(resource, StandardCharsets.UTF_8))); } - protected String login() { - LoginRequest req = new LoginRequest(); - req.setName("admin"); - req.setPassword("admin"); - try (Response response = client.target(apiBaseUrl + "user/login").request().post(Entity.json(req))) { - Assertions.assertEquals(HttpStatus.OK_200, response.getStatus()); - return response.getCookies().get("JSESSIONID").getValue(); - } + 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 client.target(apiBaseUrl + "feed/subscribe").request().post(Entity.json(subscribeRequest), Long.class); + 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) { @@ -112,19 +108,24 @@ public abstract class BaseIT { } protected Subscription getSubscription(Long subscriptionId) { - return client.target(apiBaseUrl + "feed/get/{id}").resolveTemplate("id", subscriptionId).request().get(Subscription.class); + return RestAssured.given() + .get("rest/feed/get/{id}", subscriptionId) + .then() + .statusCode(HttpStatus.SC_OK) + .extract() + .as(Subscription.class); } protected Entries getFeedEntries(long subscriptionId) { - Response response = client.target(apiBaseUrl + "feed/entries") - .queryParam("id", subscriptionId) - .queryParam("readType", "all") - .request() - .get(); - return response.readEntity(Entries.class); + return RestAssured.given() + .get("rest/feed/entries?id={id}&readType=all", subscriptionId) + .then() + .statusCode(HttpStatus.SC_OK) + .extract() + .as(Entries.class); } protected void forceRefreshAllFeeds() { - client.target(apiBaseUrl + "feed/refreshAll").request().get(Void.class); + RestAssured.given().get("rest/feed/refreshAll").then().statusCode(HttpStatus.SC_OK); } } 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 11fe4b59..171672e5 100644 --- a/commafeed-server/src/test/java/com/commafeed/integration/SecurityIT.java +++ b/commafeed-server/src/test/java/com/commafeed/integration/SecurityIT.java @@ -1,88 +1,138 @@ package com.commafeed.integration; -import java.util.Base64; +import java.net.HttpCookie; +import java.util.List; +import java.util.stream.Collectors; -import org.eclipse.jetty.http.HttpStatus; +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 jakarta.ws.rs.client.Entity; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; import jakarta.ws.rs.core.HttpHeaders; -import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.MediaType; +@QuarkusTest class SecurityIT extends BaseIT { @Test void notLoggedIn() { - try (Response response = getClient().target(getApiBaseUrl() + "user/profile").request().get()) { - Assertions.assertEquals(HttpStatus.UNAUTHORIZED_401, response.getStatus()); - } + 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() { - String auth = "Basic " + Base64.getEncoder().encodeToString("admin:wrong-password".getBytes()); - try (Response response = getClient().target(getApiBaseUrl() + "user/profile") - .request() - .header(HttpHeaders.AUTHORIZATION, auth) - .get()) { - Assertions.assertEquals(HttpStatus.UNAUTHORIZED_401, response.getStatus()); - } + RestAssured.given() + .auth() + .preemptive() + .basic("admin", "wrong-password") + .get("rest/user/profile") + .then() + .statusCode(HttpStatus.SC_UNAUTHORIZED); } @Test void missingRole() { - String auth = "Basic " + Base64.getEncoder().encodeToString("demo:demo".getBytes()); - try (Response response = getClient().target(getApiBaseUrl() + "admin/settings") - .request() - .header(HttpHeaders.AUTHORIZATION, auth) - .get()) { - Assertions.assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); - } + RestAssured.given().auth().preemptive().basic("demo", "demo").get("rest/admin/metrics").then().statusCode(HttpStatus.SC_FORBIDDEN); } @Test void apiKey() { - String auth = "Basic " + Base64.getEncoder().encodeToString("admin:admin".getBytes()); - // create api key ProfileModificationRequest req = new ProfileModificationRequest(); req.setCurrentPassword("admin"); req.setNewApiKey(true); - getClient().target(getApiBaseUrl() + "user/profile") - .request() - .header(HttpHeaders.AUTHORIZATION, auth) - .post(Entity.json(req)) - .close(); + 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 = getClient().target(getApiBaseUrl() + "user/profile") - .request() - .header(HttpHeaders.AUTHORIZATION, auth) - .get(UserModel.class) + 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 = getClient().target(getApiBaseUrl() + "feed/subscribe") - .request() - .header(HttpHeaders.AUTHORIZATION, auth) - .post(Entity.json(subscribeRequest), Long.class); + 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 = getClient().target(getApiBaseUrl() + "feed/entries") + Entries entries = RestAssured.given() .queryParam("id", subscriptionId) .queryParam("readType", "unread") .queryParam("apiKey", apiKey) - .request() - .get(Entries.class); + .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/WebSocketIT.java b/commafeed-server/src/test/java/com/commafeed/integration/WebSocketIT.java index 7cec67db..bfeb64ea 100644 --- a/commafeed-server/src/test/java/com/commafeed/integration/WebSocketIT.java +++ b/commafeed-server/src/test/java/com/commafeed/integration/WebSocketIT.java @@ -1,6 +1,7 @@ package com.commafeed.integration; import java.io.IOException; +import java.net.HttpCookie; import java.net.URI; import java.util.Collections; import java.util.List; @@ -8,15 +9,18 @@ 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 org.apache.hc.core5.http.HttpStatus; import org.awaitility.Awaitility; -import org.glassfish.jersey.client.JerseyClientBuilder; -import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature; 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 jakarta.websocket.ClientEndpointConfig; import jakarta.websocket.CloseReason; import jakarta.websocket.ContainerProvider; @@ -24,15 +28,17 @@ import jakarta.websocket.DeploymentException; import jakarta.websocket.Endpoint; import jakarta.websocket.EndpointConfig; import jakarta.websocket.Session; -import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; import lombok.extern.slf4j.Slf4j; +@QuarkusTest @Slf4j class WebSocketIT extends BaseIT { - @Override - protected JerseyClientBuilder configureClientBuilder(JerseyClientBuilder base) { - return base.register(HttpAuthenticationFeature.basic("admin", "admin")); + @BeforeEach + void setup() { + RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); } @Test @@ -49,18 +55,17 @@ class WebSocketIT extends BaseIT { public void onClose(Session session, CloseReason closeReason) { closeReasonRef.set(closeReason); } - }, buildConfig("fake-session-id"), URI.create(getWebSocketUrl()))) { + }, 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); - Assertions.assertEquals(CloseReason.CloseCodes.VIOLATED_POLICY, closeReasonRef.get().getCloseCode()); } } @Test void subscribeAndGetsNotified() throws DeploymentException, IOException { - String sessionId = login(); + List cookies = login(); AtomicBoolean connected = new AtomicBoolean(); AtomicReference messageRef = new AtomicReference<>(); @@ -70,7 +75,7 @@ class WebSocketIT extends BaseIT { session.addMessageHandler(String.class, messageRef::set); connected.set(true); } - }, buildConfig(sessionId), URI.create(getWebSocketUrl()))) { + }, buildConfig(cookies), URI.create(getWebSocketUrl()))) { Awaitility.await().atMost(15, TimeUnit.SECONDS).untilTrue(connected); log.info("connected to {}", session.getRequestURI()); @@ -83,14 +88,14 @@ class WebSocketIT extends BaseIT { @Test void notNotifiedForFilteredEntries() throws DeploymentException, IOException { - String sessionId = login(); + List cookies = login(); Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); FeedModificationRequest req = new FeedModificationRequest(); req.setId(subscriptionId); req.setName("feed-name"); req.setFilter("!title.contains('item 4')"); - getClient().target(getApiBaseUrl() + "feed/modify").request().post(Entity.json(req), Void.class); + 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<>(); @@ -100,7 +105,7 @@ class WebSocketIT extends BaseIT { session.addMessageHandler(String.class, messageRef::set); connected.set(true); } - }, buildConfig(sessionId), URI.create(getWebSocketUrl()))) { + }, buildConfig(cookies), URI.create(getWebSocketUrl()))) { Awaitility.await().atMost(15, TimeUnit.SECONDS).untilTrue(connected); log.info("connected to {}", session.getRequestURI()); @@ -115,7 +120,7 @@ class WebSocketIT extends BaseIT { @Test void pingPong() throws DeploymentException, IOException { - String sessionId = login(); + List cookies = login(); AtomicBoolean connected = new AtomicBoolean(); AtomicReference messageRef = new AtomicReference<>(); @@ -125,7 +130,7 @@ class WebSocketIT extends BaseIT { session.addMessageHandler(String.class, messageRef::set); connected.set(true); } - }, buildConfig(sessionId), URI.create(getWebSocketUrl()))) { + }, buildConfig(cookies), URI.create(getWebSocketUrl()))) { Awaitility.await().atMost(15, TimeUnit.SECONDS).untilTrue(connected); log.info("connected to {}", session.getRequestURI()); @@ -136,11 +141,12 @@ class WebSocketIT extends BaseIT { } } - private ClientEndpointConfig buildConfig(String sessionId) { + private ClientEndpointConfig buildConfig(List cookies) { return ClientEndpointConfig.Builder.create().configurator(new ClientEndpointConfig.Configurator() { @Override public void beforeRequest(Map> headers) { - headers.put("Cookie", Collections.singletonList("JSESSIONID=" + sessionId)); + 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 138fd821..ceeef49f 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,33 +1,28 @@ package com.commafeed.integration.rest; -import java.util.Arrays; import java.util.List; -import org.glassfish.jersey.client.JerseyClientBuilder; -import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature; +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 com.commafeed.CommaFeedConfiguration.ApplicationSettings; 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 jakarta.ws.rs.client.Entity; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import jakarta.ws.rs.core.MediaType; +@QuarkusTest class AdminIT extends BaseIT { - @Override - protected JerseyClientBuilder configureClientBuilder(JerseyClientBuilder base) { - return base.register(HttpAuthenticationFeature.basic("admin", "admin")); - } - - @Test - void getApplicationSettings() { - ApplicationSettings settings = getClient().target(getApiBaseUrl() + "admin/settings").request().get(ApplicationSettings.class); - Assertions.assertTrue(settings.getAllowRegistrations()); + @BeforeEach + void setup() { + RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); } @Nested @@ -51,7 +46,12 @@ class AdminIT extends BaseIT { user.setName("test"); user.setPassword("test".getBytes()); user.setEmail("test@test.com"); - getClient().target(getApiBaseUrl() + "admin/user/save").request().post(Entity.json(user), Void.TYPE); + RestAssured.given() + .body(user) + .contentType(MediaType.APPLICATION_JSON) + .post("rest/admin/user/save") + .then() + .statusCode(HttpStatus.SC_OK); } private void modifyUser() { @@ -61,7 +61,12 @@ class AdminIT extends BaseIT { .findFirst() .orElseThrow(() -> new NullPointerException("User not found")); user.setEmail("new-email@provider.com"); - getClient().target(getApiBaseUrl() + "admin/user/save").request().post(Entity.json(user), Void.TYPE); + RestAssured.given() + .body(user) + .contentType(MediaType.APPLICATION_JSON) + .post("rest/admin/user/save") + .then() + .statusCode(HttpStatus.SC_OK); } private void deleteUser() { @@ -73,11 +78,17 @@ class AdminIT extends BaseIT { IDRequest req = new IDRequest(); req.setId(user.getId()); - getClient().target(getApiBaseUrl() + "admin/user/delete").request().post(Entity.json(req), Void.TYPE); + RestAssured.given() + .body(req) + .contentType(MediaType.APPLICATION_JSON) + .post("rest/admin/user/delete") + .then() + .statusCode(HttpStatus.SC_OK); } private List getAllUsers() { - return Arrays.asList(getClient().target(getApiBaseUrl() + "admin/user/getAll").request().get(UserModel[].class)); + 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 c5c6c2a5..271b3556 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 @@ -9,15 +9,10 @@ import java.time.ZoneOffset; import java.util.Objects; import org.apache.commons.io.IOUtils; -import org.apache.commons.lang3.StringUtils; +import org.apache.hc.core5.http.HttpStatus; import org.awaitility.Awaitility; -import org.eclipse.jetty.http.HttpStatus; -import org.glassfish.jersey.client.ClientProperties; -import org.glassfish.jersey.client.JerseyClientBuilder; -import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature; -import org.glassfish.jersey.media.multipart.MultiPart; -import org.glassfish.jersey.media.multipart.file.StreamDataBodyPart; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -30,15 +25,17 @@ import com.commafeed.frontend.model.request.IDRequest; import com.commafeed.frontend.model.request.MarkRequest; import com.commafeed.integration.BaseIT; -import jakarta.ws.rs.client.Entity; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; +@QuarkusTest class FeedIT extends BaseIT { - @Override - protected JerseyClientBuilder configureClientBuilder(JerseyClientBuilder base) { - return base.register(HttpAuthenticationFeature.basic("admin", "admin")); + @BeforeEach + void setup() { + RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); } @Nested @@ -48,7 +45,14 @@ class FeedIT extends BaseIT { FeedInfoRequest req = new FeedInfoRequest(); req.setUrl(getFeedUrl()); - FeedInfo feedInfo = getClient().target(getApiBaseUrl() + "feed/fetch").request().post(Entity.json(req), FeedInfo.class); + 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()); } @@ -64,33 +68,37 @@ class FeedIT extends BaseIT { @Test void subscribeFromUrl() { - try (Response response = getClient().target(getApiBaseUrl() + "feed/subscribe") + RestAssured.given() .queryParam("url", getFeedUrl()) - .property(ClientProperties.FOLLOW_REDIRECTS, Boolean.FALSE) - .request() - .get()) { - Assertions.assertEquals(HttpStatus.TEMPORARY_REDIRECT_307, response.getStatus()); - } + .redirects() + .follow(false) + .get("rest/feed/subscribe") + .then() + .statusCode(HttpStatus.SC_TEMPORARY_REDIRECT); } @Test void unsubscribeFromUnknownFeed() { - Assertions.assertEquals(HttpStatus.NOT_FOUND_404, unsubsribe(1L)); + Assertions.assertEquals(HttpStatus.SC_NOT_FOUND, unsubsribe(1L)); } @Test void unsubscribeFromKnownFeed() { long subscriptionId = subscribe(getFeedUrl()); - Assertions.assertEquals(HttpStatus.OK_200, unsubsribe(subscriptionId)); + Assertions.assertEquals(HttpStatus.SC_OK, unsubsribe(subscriptionId)); } private int unsubsribe(long subscriptionId) { IDRequest request = new IDRequest(); request.setId(subscriptionId); - try (Response response = getClient().target(getApiBaseUrl() + "feed/unsubscribe").request().post(Entity.json(request))) { - return response.getStatus(); - } + return RestAssured.given() + .body(request) + .contentType(MediaType.APPLICATION_JSON) + .post("rest/feed/unsubscribe") + .then() + .extract() + .statusCode(); } } @@ -136,7 +144,13 @@ class FeedIT extends BaseIT { request.setId(String.valueOf(subscriptionId)); request.setOlderThan(olderThan == null ? null : olderThan.toEpochMilli()); request.setInsertedBefore(insertedBefore == null ? null : insertedBefore.toEpochMilli()); - getClient().target(getApiBaseUrl() + "feed/mark").request().post(Entity.json(request), Void.TYPE); + + RestAssured.given() + .body(request) + .contentType(MediaType.APPLICATION_JSON) + .post("rest/feed/mark") + .then() + .statusCode(HttpStatus.SC_OK); } } @@ -151,7 +165,12 @@ class FeedIT extends BaseIT { IDRequest request = new IDRequest(); request.setId(subscriptionId); - getClient().target(getApiBaseUrl() + "feed/refresh").request().post(Entity.json(request), Void.TYPE); + RestAssured.given() + .body(request) + .contentType(MediaType.APPLICATION_JSON) + .post("rest/feed/refresh") + .then() + .statusCode(HttpStatus.SC_OK); Awaitility.await() .atMost(Duration.ofSeconds(15)) @@ -164,7 +183,7 @@ class FeedIT extends BaseIT { // mariadb/mysql timestamp precision is 1 second Instant threshold = Instant.now().minus(Duration.ofSeconds(1)); - getClient().target(getApiBaseUrl() + "feed/refreshAll").request().get(Void.TYPE); + forceRefreshAllFeeds(); Awaitility.await() .atMost(Duration.ofSeconds(15)) @@ -184,7 +203,12 @@ class FeedIT extends BaseIT { req.setId(subscriptionId); req.setName("new name"); req.setCategoryId(subscription.getCategoryId()); - getClient().target(getApiBaseUrl() + "feed/modify").request().post(Entity.json(req), Void.TYPE); + 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()); @@ -197,10 +221,14 @@ class FeedIT extends BaseIT { void favicon() throws IOException { Long subscriptionId = subscribe(getFeedUrl()); - byte[] icon = getClient().target(getApiBaseUrl() + "feed/favicon/{id}") - .resolveTemplate("id", subscriptionId) - .request() - .get(byte[].class); + 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); } @@ -209,35 +237,20 @@ class FeedIT extends BaseIT { @Nested class Opml { @Test - void importExportOpml() throws IOException { + void importExportOpml() { importOpml(); - String opml = getClient().target(getApiBaseUrl() + "feed/export").request().get(String.class); - String expextedOpml = """ - - - - admin subscriptions in CommaFeed - - - - - - - - """; - Assertions.assertEquals(StringUtils.normalizeSpace(expextedOpml), StringUtils.normalizeSpace(opml)); + 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() throws IOException { + void importOpml() { InputStream stream = Objects.requireNonNull(getClass().getResourceAsStream("/opml/opml_v2.0.xml")); - try (MultiPart multiPart = new MultiPart()) { - multiPart.bodyPart(new StreamDataBodyPart("file", stream)); - multiPart.setMediaType(MediaType.MULTIPART_FORM_DATA_TYPE); - getClient().target(getApiBaseUrl() + "feed/import") - .request() - .post(Entity.entity(multiPart, multiPart.getMediaType()), Void.TYPE); - } + 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 8f8ea1a0..533e034b 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,7 +1,6 @@ package com.commafeed.integration.rest; -import org.glassfish.jersey.client.JerseyClientBuilder; -import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature; +import org.apache.hc.core5.http.HttpStatus; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -12,29 +11,28 @@ import com.commafeed.frontend.model.request.ProfileModificationRequest; import com.commafeed.frontend.resource.fever.FeverResponse; import com.commafeed.integration.BaseIT; -import jakarta.ws.rs.client.Entity; -import jakarta.ws.rs.core.Form; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import jakarta.ws.rs.core.MediaType; +@QuarkusTest class FeverIT extends BaseIT { private Long userId; private String apiKey; - @Override - protected JerseyClientBuilder configureClientBuilder(JerseyClientBuilder base) { - return base.register(HttpAuthenticationFeature.basic("admin", "admin")); - } - @BeforeEach - void init() { + void setup() { + RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); + // create api key ProfileModificationRequest req = new ProfileModificationRequest(); req.setCurrentPassword("admin"); req.setNewApiKey(true); - getClient().target(getApiBaseUrl() + "user/profile").request().post(Entity.json(req), Void.TYPE); + RestAssured.given().body(req).contentType(MediaType.APPLICATION_JSON).post("rest/user/profile").then().statusCode(HttpStatus.SC_OK); // retrieve api key - UserModel user = getClient().target(getApiBaseUrl() + "user/profile").request().get(UserModel.class); + 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(); } @@ -70,12 +68,15 @@ class FeverIT extends BaseIT { } private FeverResponse fetch(String what, String apiKey) { - Form form = new Form(); - form.param("api_key", Digests.md5Hex("admin:" + apiKey)); - form.param(what, "1"); - return getClient().target(getApiBaseUrl() + "fever/user/{userId}") - .resolveTemplate("userId", userId) - .request() - .post(Entity.form(form), FeverResponse.class); + 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 82201f6f..3977d967 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 @@ -6,17 +6,21 @@ 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 = getClient().target(getApiBaseUrl() + "server/get").request().get(ServerInfo.class); + 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(10000, serverInfos.getTreeReloadInterval()); + Assertions.assertEquals(30000, serverInfos.getTreeReloadInterval()); } } 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 3eccde06..2e7b6cb4 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,44 +1,46 @@ package com.commafeed.integration.rest; +import java.util.List; + 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.RegisterExtension; import com.commafeed.frontend.model.request.PasswordResetRequest; import com.commafeed.integration.BaseIT; -import com.icegreen.greenmail.junit5.GreenMailExtension; -import com.icegreen.greenmail.util.ServerSetupTest; -import jakarta.mail.internet.MimeMessage; -import jakarta.ws.rs.client.Entity; +import io.quarkus.mailer.MockMailbox; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import io.vertx.ext.mail.MailMessage; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.MediaType; +@QuarkusTest class UserIT extends BaseIT { - @Nested - class PasswordReset { + @Inject + MockMailbox mailbox; - @RegisterExtension - static final GreenMailExtension GREEN_MAIL = new GreenMailExtension(ServerSetupTest.SMTP); + @BeforeEach + void setup() { + RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); - @BeforeEach - void init() { - GREEN_MAIL.setUser("noreply@commafeed.com", "user", "pass"); - } + mailbox.clear(); + } - @Test - void resetPassword() throws Exception { - PasswordResetRequest req = new PasswordResetRequest(); - req.setEmail("admin@commafeed.com"); + @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); - getClient().target(getApiBaseUrl() + "user/passwordReset").request().post(Entity.json(req), Void.TYPE); + List mails = mailbox.getMailMessagesSentTo("admin@commafeed.com"); + Assertions.assertEquals(1, mails.size()); - MimeMessage message = GREEN_MAIL.getReceivedMessages()[0]; - Assertions.assertEquals("CommaFeed - Password recovery", message.getSubject()); - Assertions.assertTrue(message.getContent().toString().startsWith("You asked for password recovery for account 'admin'")); - Assertions.assertEquals("CommaFeed ", message.getFrom()[0].toString()); - Assertions.assertEquals("admin@commafeed.com", message.getAllRecipients()[0].toString()); - } + 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 0a903fb5..de6947e1 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,47 +1,42 @@ package com.commafeed.integration.servlet; -import org.glassfish.jersey.client.JerseyClientBuilder; -import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature; -import org.junit.jupiter.api.Assertions; +import org.apache.hc.core5.http.HttpStatus; +import org.hamcrest.CoreMatchers; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import com.commafeed.frontend.model.Settings; import com.commafeed.integration.BaseIT; -import jakarta.ws.rs.client.Entity; -import jakarta.ws.rs.core.HttpHeaders; -import jakarta.ws.rs.core.Response; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import jakarta.ws.rs.core.MediaType; +@QuarkusTest class CustomCodeIT extends BaseIT { - @Override - protected JerseyClientBuilder configureClientBuilder(JerseyClientBuilder base) { - return base.register(HttpAuthenticationFeature.basic("admin", "admin")); + @BeforeEach + void setup() { + RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); } @Test void test() { // get settings - Settings settings = getClient().target(getApiBaseUrl() + "user/settings").request().get(Settings.class); + 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"); - getClient().target(getApiBaseUrl() + "user/settings").request().post(Entity.json(settings), Void.TYPE); + RestAssured.given() + .body(settings) + .contentType(MediaType.APPLICATION_JSON) + .post("rest/user/settings") + .then() + .statusCode(HttpStatus.SC_OK); // check custom code servlets - String cookie = login(); - try (Response response = getClient().target(getBaseUrl() + "custom_js.js") - .request() - .header(HttpHeaders.COOKIE, "JSESSIONID=" + cookie) - .get()) { - Assertions.assertEquals("custom-js", response.readEntity(String.class)); - } - try (Response response = getClient().target(getBaseUrl() + "custom_css.css") - .request() - .header(HttpHeaders.COOKIE, "JSESSIONID=" + cookie) - .get()) { - Assertions.assertEquals("custom-css", response.readEntity(String.class)); - } + 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 10e97246..9feb018c 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,32 +1,37 @@ package com.commafeed.integration.servlet; -import org.eclipse.jetty.http.HttpStatus; -import org.glassfish.jersey.client.ClientProperties; +import java.net.HttpCookie; +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.hc.core5.http.HttpStatus; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -import com.commafeed.frontend.model.UserModel; import com.commafeed.integration.BaseIT; -import jakarta.ws.rs.NotAuthorizedException; -import jakarta.ws.rs.client.Invocation.Builder; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import io.restassured.http.Headers; import jakarta.ws.rs.core.HttpHeaders; -import jakarta.ws.rs.core.Response; +@QuarkusTest class LogoutIT extends BaseIT { @Test void test() { - String cookie = login(); - try (Response response = getClient().target(getBaseUrl() + "logout") - .request() - .header(HttpHeaders.COOKIE, "JSESSIONID=" + cookie) - .property(ClientProperties.FOLLOW_REDIRECTS, Boolean.FALSE) - .get()) { - Assertions.assertEquals(HttpStatus.FOUND_302, response.getStatus()); - } + 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(); - Builder req = getClient().target(getApiBaseUrl() + "user/profile").request().header(HttpHeaders.COOKIE, "JSESSIONID=" + cookie); - Assertions.assertThrows(NotAuthorizedException.class, () -> req.get(UserModel.class)); + 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 cb6ada00..661275fd 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,36 +1,34 @@ package com.commafeed.integration.servlet; -import org.eclipse.jetty.http.HttpStatus; -import org.glassfish.jersey.client.ClientProperties; -import org.glassfish.jersey.client.JerseyClientBuilder; -import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature; -import org.junit.jupiter.api.Assertions; +import org.apache.hc.core5.http.HttpStatus; +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; import jakarta.ws.rs.core.HttpHeaders; -import jakarta.ws.rs.core.Response; +@QuarkusTest class NextUnreadIT extends BaseIT { - @Override - protected JerseyClientBuilder configureClientBuilder(JerseyClientBuilder base) { - return base.register(HttpAuthenticationFeature.basic("admin", "admin")); + @BeforeEach + void setup() { + RestAssured.authentication = RestAssured.preemptive().basic("admin", "admin"); } @Test void test() { subscribeAndWaitForEntries(getFeedUrl()); - String cookie = login(); - Response response = getClient().target(getBaseUrl() + "next") - .property(ClientProperties.FOLLOW_REDIRECTS, Boolean.FALSE) - .request() - .header(HttpHeaders.COOKIE, "JSESSIONID=" + cookie) - .get(); - Assertions.assertEquals(HttpStatus.FOUND_302, response.getStatus()); - Assertions.assertEquals("https://hostname.local/commafeed/2", response.getHeaderString(HttpHeaders.LOCATION)); + 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 142b0c27..aa153217 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.junit.jupiter.api.Assertions; +import org.hamcrest.CoreMatchers; import org.junit.jupiter.api.Test; import com.commafeed.integration.BaseIT; -import jakarta.ws.rs.core.Response; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +@QuarkusTest class RobotsTxtIT extends BaseIT { @Test void test() { - try (Response response = getClient().target(getBaseUrl() + "robots.txt").request().get()) { - Assertions.assertEquals("User-agent: *\nDisallow: /", response.readEntity(String.class)); - } + RestAssured.given().get("robots.txt").then().statusCode(200).body(CoreMatchers.is("User-agent: *\nDisallow: /")); } } diff --git a/commafeed-server/src/test/resources/config.test.yml b/commafeed-server/src/test/resources/config.test.yml deleted file mode 100644 index 209f3efc..00000000 --- a/commafeed-server/src/test/resources/config.test.yml +++ /dev/null @@ -1,138 +0,0 @@ -# CommaFeed settings -# ------------------ -app: - # url used to access commafeed - publicUrl: http://localhost:8082/ - - # whether to expose a robots.txt file that disallows web crawlers and search engine indexers - hideFromWebCrawlers: true - - # whether to allow user registrations - allowRegistrations: true - - # whether to enable strict password validation (1 uppercase char, 1 lowercase char, 1 digit, 1 special char) - strictPasswordPolicy: true - - # create a demo account the first time the app starts - createDemoAccount: true - - # put your google analytics tracking code here - googleAnalyticsTrackingCode: - - # put your google server key (used for youtube favicon fetching) - googleAuthKey: - - # number of http threads - backgroundThreads: 3 - - # number of database updating threads - databaseUpdateThreads: 1 - - # rows to delete per query while cleaning up old entries - databaseCleanupBatchSize: 100 - - # settings for sending emails (password recovery) - smtpHost: localhost - smtpPort: 3025 - smtpTls: false - smtpUserName: user - smtpPassword: pass - smtpFromAddress: noreply@commafeed.com - - # Graphite Metric settings - # Allows those who use Graphite to have CommaFeed send metrics for graphing (time in seconds) - graphiteEnabled: false - graphitePrefix: "test.commafeed" - graphiteHost: "localhost" - graphitePort: 2003 - graphiteInterval: 60 - - # whether this commafeed instance has a lot of feeds to refresh - # leave this to false in almost all cases - heavyLoad: false - - # minimum amount of time commafeed will wait before refreshing the same feed - refreshIntervalMinutes: 5 - - # if enabled, images in feed entries will be proxied through the server instead of accessed directly by the browser - # useful if commafeed is usually accessed through a restricting proxy - imageProxyEnabled: false - - # database query timeout (in milliseconds), 0 to disable - queryTimeout: 0 - - # time to keep unread statuses (in days), 0 to disable - keepStatusDays: 0 - - # entries to keep per feed, old entries will be deleted, 0 to disable - maxFeedCapacity: 500 - - # entries older than this will be deleted, 0 to disable - maxEntriesAgeDays: 0 - - # limit the number of feeds a user can subscribe to, 0 to disable - maxFeedsPerUser: 0 - - # don't parse feeds that are too large to prevent memory issues - maxFeedResponseSize: 5M - - # cache service to use, possible values are 'noop' and 'redis' - cache: noop - - # announcement string displayed on the main page - announcement: - - # user-agent string that will be used by the http client, leave empty for the default one - userAgent: - - # enable websocket connection so the server can notify the web client that there are new entries for your feeds - websocketEnabled: true - - # interval at which the client will send a ping message on the websocket to keep the connection alive - websocketPingInterval: 15m - - # if websocket is disabled or the connection is lost, the client will reload the feed tree at this interval - treeReloadInterval: 10s - -# Database connection -# ------------------- -# for MariaDB -# driverClass is org.mariadb.jdbc.Driver -# url is jdbc:mariadb://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC -# -# for MySQL -# driverClass is com.mysql.cj.jdbc.Driver -# url is jdbc:mysql://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC -# -# for PostgreSQL -# driverClass is org.postgresql.Driver -# url is jdbc:postgresql://localhost:5432/commafeed - -database: - driverClass: org.h2.Driver - url: jdbc:h2:mem:commafeed - user: sa - password: sa - properties: - charSet: UTF-8 - validationQuery: "/* CommaFeed Health Check */ SELECT 1" - minSize: 1 - maxSize: 5 - initialSize: 1 - -server: - applicationConnectors: - - type: http - port: 8083 - adminConnectors: [ ] - -logging: - level: INFO - loggers: - com.commafeed: DEBUG - liquibase: INFO - org.hibernate.SQL: INFO # or ALL for sql debugging - org.hibernate.engine.internal.StatisticalLoggingSessionEventListener: WARN - appenders: - - type: console - \ No newline at end of file diff --git a/commafeed-server/src/test/resources/docker-images.properties b/commafeed-server/src/test/resources/docker-images.properties deleted file mode 100644 index b40aa81e..00000000 --- a/commafeed-server/src/test/resources/docker-images.properties +++ /dev/null @@ -1,4 +0,0 @@ -postgresql=postgres:${postgresql.image.version} -mysql=mysql:${mysql.image.version} -mariadb=mariadb:${mariadb.image.version} -redis=redis:${redis.image.version} \ No newline at end of file diff --git a/commafeed-server/src/test/resources/h2-migration/database-v2.1.214.mv.db b/commafeed-server/src/test/resources/h2-migration/database-v2.1.214.mv.db deleted file mode 100644 index b11490bc..00000000 Binary files a/commafeed-server/src/test/resources/h2-migration/database-v2.1.214.mv.db and /dev/null differ diff --git a/commafeed-server/src/test/resources/logback-test.xml b/commafeed-server/src/test/resources/logback-test.xml deleted file mode 100644 index a452f81a..00000000 --- a/commafeed-server/src/test/resources/logback-test.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - %-5p %d{ISO8601} [%thread] [%c{0}:%L] %m %rEx%n - - - - - - - \ No newline at end of file diff --git a/pom.xml b/pom.xml index da9fb825..7d321f30 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ com.commafeed commafeed - 4.6.0 + 5.0.0-beta CommaFeed pom