diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index de9ca4af0..000000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,59 +0,0 @@
-# Java Maven CircleCI 2.0 configuration file
-#
-# Check https://circleci.com/docs/2.0/language-java/ for more details
-#
-version: 2
-jobs:
- build:
- docker:
- - image: circleci/openjdk:10-jdk-browsers
-
- working_directory: ~/repo
-
- environment:
- - MAVEN_OPTS: -Xmx3200m
- - PHANTOMJSBIN: /usr/local/bin/phantomjs
-
- steps:
- - checkout
-
- - restore_cache:
- keys:
- - v1-dependencies-{{ checksum "pom.xml" }}
- - v1-dependencies-
-
- - run: mkdir -p ~/junit/
-
- #Clean install all but without integration tests
- - run: mvn -T 2 -pl "!org.nzbhydra:tests,!org.nzbhydra:linux-release,!org.nzbhydra:windows-release" clean install dependency:resolve-plugins dependency:go-offline
- - run: find . -type f -regex ".*/target/surefire-reports/.*xml" -exec cp {} ~/junit/ \;
- - run: find . -type f -regex ".*/target/screenshots/.*png" -exec cp {} ~/junit/ \;
-
-
- # Integration tests produce weird transaction errors when run with maven :-/
- # #Run integration tests
- # - run: mvn -f tests/pom.xml test
- # - run: find . -type f -regex ".*/target/surefire-reports/.*xml" -exec cp {} ~/junit/ \;
- # - run: find . -type f -regex ".*/target/screenshots/.*png" -exec cp {} ~/junit/ \;
-
- #Build releases
- # - run: mkdir -p ~/releases/
- # - run: mvn -T 2 -pl "org.nzbhydra:linux-release,org.nzbhydra:windows-release" clean install dependency:resolve-plugins dependency:go-offline
- # - run: cp releases/linux-release/target/*.zip ~/releases/
- # - run: cp releases/windows-release/target/*.zip ~/releases/
-
- #Collect all surefire reports and put them in one folder for Circle to parse
- - save_cache:
- paths:
- - ~/.m2
- key: v1-dependencies-{{ checksum "pom.xml" }}
-
- - store_test_results:
- path: ~/junit
-# - store_artifacts:
-# path: ~/junit
-# - store_artifacts:
-# path: ~/releases
-
-#workflows didn't work because I cached the m2 cache in the build step and then restored it in the test step. without a changed pom.xml old JARs would be used
-#possible fix: another more specific save_cache for ~/.m2/repository/org/nzbhydra/
\ No newline at end of file
diff --git a/.github/workflows/buildNative.yml b/.github/workflows/buildNative.yml
new file mode 100644
index 000000000..2eb8b9481
--- /dev/null
+++ b/.github/workflows/buildNative.yml
@@ -0,0 +1,112 @@
+name: Native Build
+
+on:
+ workflow_dispatch:
+ workflow_call:
+
+jobs:
+ build:
+ strategy:
+ matrix:
+ os: [ ubuntu-20.04 ]
+ fail-fast: false
+ env:
+ HYDRA_NATIVE_BUILD: true
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ # Check out last 15 commits
+ fetch-depth: 15
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'adopt'
+ cache: 'maven'
+
+ - name: "Get changed files in core module"
+ id: changed-files-specific
+ uses: tj-actions/changed-files@v35
+ with:
+ since_last_remote_commit: true
+ files: |
+ core/**
+
+ - if: steps.changed-files-specific.outputs.any_changed == 'true'
+ run: |
+ echo "::notice::Will build new native image / docker container. Changed files in core since last push:"
+ echo "${{ steps.changed-files-specific.outputs.all_changed_files }}"
+
+ - if: steps.changed-files-specific.outputs.any_changed == 'false'
+ run: |
+ echo "::notice::Will skip build of native image / docker container. No changed files in core since last push."
+
+ - name: NativeImage
+ uses: graalvm/setup-graalvm@v1
+ if: steps.changed-files-specific.outputs.any_changed == 'true'
+ with:
+ java-version: '17'
+ version: 'latest'
+ components: 'native-image'
+ cache: 'maven'
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: "Install all with Maven"
+
+ run: mvn --batch-mode clean install -DskipTests -T 1C
+ - name: "Run unit tests"
+ run: mvn --batch-mode test -T 1C -pl !org.nzbhydra:tests,!org.nzbhydra.tests:system --fail-at-end
+
+ - name: "Create test Report"
+ uses: dorny/test-reporter@v1
+ if: always()
+ continue-on-error: true
+ with:
+ name: Unit test report
+ path: "**/surefire-reports/*.xml"
+ reporter: java-junit
+
+ - name: "Build native image"
+ if: steps.changed-files-specific.outputs.any_changed == 'true'
+ working-directory: ./core
+ run: |
+ mvn --batch-mode -Pnative clean native:compile -DskipTests
+
+ - name: "UPX linux artifact"
+ if: steps.changed-files-specific.outputs.any_changed == 'true'
+ uses: crazy-max/ghaction-upx@v2
+ with:
+ files: core/target/core
+ args: -q
+
+ - name: "Upload linux artifact"
+ if: steps.changed-files-specific.outputs.any_changed == 'true'
+ uses: actions/upload-artifact@v3
+ with:
+ name: coreLinux
+ path: core/target/core
+
+ - name: "Copy artifact to include folder"
+ if: steps.changed-files-specific.outputs.any_changed == 'true'
+ run: |
+ mv core/target/core ./docker/nativeTest/
+ chmod +x ./docker/nativeTest/core
+
+ - name: "Login to GitHub Container Registry"
+ if: steps.changed-files-specific.outputs.any_changed == 'true'
+ uses: docker/login-action@v2
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: "Build core and push container"
+ if: steps.changed-files-specific.outputs.any_changed == 'true'
+ run: |
+ cp other/wrapper/nzbhydra2wrapperPy3.py ./docker/nativeTest/
+ cd ./docker/nativeTest/
+ docker build -t hydradocker .
+ docker tag hydradocker:latest ghcr.io/theotherp/hydradocker:latest
+ docker push ghcr.io/theotherp/hydradocker:latest
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 000000000..176b8363d
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,57 @@
+name: Release
+
+on:
+ workflow_dispatch:
+ inputs:
+ releaseVersion:
+ required: true
+ type: string
+ description: "Version to be released, like 1.2.3"
+ nextVersion:
+ required: true
+ type: string
+ description: "Version to be set afterwards, like 1.2.4-SNAPSHOT"
+ dryRun:
+ required: true
+ default: true
+ type: boolean
+ description: "Uncheck to actually execute the release"
+ selfHostedRunner:
+ required: true
+ default: false
+ type: boolean
+ description: "Has no effect, just as a reminder that the self-hosted windows runner must be running"
+
+jobs:
+ build:
+ uses: ./.github/workflows/buildNative.yml
+ release:
+ needs: [build]
+ runs-on: ubuntu-latest
+ env:
+ githubReleasesUrl: https://api.github.com/repos/{{github.repository}}/releases
+ steps:
+ - uses: actions/checkout@v3
+ name: "Check out source"
+ - name: "Display structure of working directory"
+ run: ls .
+ - uses: actions/download-artifact@v3
+ name: "Download native artifacts"
+ with:
+ path: ~/artifacts
+ - name: "Display structure of artifacts folder"
+ run: ls -R ~/artifacts
+ - name: "Copy artifacts to include folders"
+ run: |
+ mv ~/artifacts/coreLinux/* ./releases/linux-release/include/
+ chmod +x ./releases/linux-release/include/nzbhydra2
+ mv ~/artifacts/coreWindows/* ./releases/windows-release/include/
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'adopt'
+ cache: 'maven'
+ - name: "Run release script"
+ run: |
+ misc/build-and-release.sh ${{ github.event.inputs.releaseVersion }} ${{ github.event.inputs.nextVersion }} ${{ github.event.inputs.dryRun }}
diff --git a/.github/workflows/system-test.yml b/.github/workflows/system-test.yml
new file mode 100644
index 000000000..a93e87770
--- /dev/null
+++ b/.github/workflows/system-test.yml
@@ -0,0 +1,130 @@
+name: system-test
+
+on:
+ push:
+ workflow_dispatch:
+
+jobs:
+ waitForNative:
+ uses: ./.github/workflows/buildNative.yml
+ buildMockserver:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ name: "Check out source"
+ with:
+ # Check out last 15 commits
+ fetch-depth: 15
+
+ - name: "Get changed files in core module"
+ id: changed-files-specific
+ uses: tj-actions/changed-files@v35
+ with:
+ since_last_remote_commit: true
+ files: |
+ other/mockserver/**
+
+ - if: steps.changed-files-specific.outputs.any_changed == 'true'
+ run: |
+ echo "::notice::Will build new mock server container. Changed files since last push:"
+ echo "${{ steps.changed-files-specific.outputs.all_changed_files }}"
+
+ - if: steps.changed-files-specific.outputs.any_changed == 'false'
+ run: |
+ echo "::notice::Will skip build of new mock server container. No changed files since last push."
+
+ - name: Set up JDK 17
+ if: steps.changed-files-specific.outputs.any_changed == 'true'
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'adopt'
+ cache: 'maven'
+
+ - name: "Login to GitHub Container Registry"
+ if: steps.changed-files-specific.outputs.any_changed == 'true'
+ uses: docker/login-action@v2
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: "Build and push mockserver container"
+ if: steps.changed-files-specific.outputs.any_changed == 'true'
+ run: |
+ mvn --batch-mode install -DskipTests -T 1C
+ cd other/mockserver/
+ mvn --batch-mode spring-boot:build-image
+ docker tag mockserver:3.0.0 ghcr.io/theotherp/mockserver:3.0.0
+ docker push ghcr.io/theotherp/mockserver:3.0.0
+
+ runSystemTests:
+ needs: [ waitForNative, buildMockserver ]
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ test: [ { port: 5076, name: core }, { port: 5077, name: v1Migration } ]
+ env:
+ spring_profiles_active: build,systemtest
+ nzbhydra_port: ${{ matrix.test.port }}
+ nzbhydra_name: ${{ matrix.test.name }}
+ nzbhydra_host_external: http://${{ matrix.test.name }}:5076
+ nzbhydra.host.external: http://${{ matrix.test.name }}:5076
+ steps:
+ - run: echo Running test ${{ matrix.test.name }} with port ${{ matrix.test.port }}
+ - uses: actions/checkout@v3
+ name: "Check out source"
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'adopt'
+ cache: 'maven'
+
+ - name: "Install"
+ run: mvn --batch-mode clean install -DskipTests -pl org.nzbhydra:nzbhydra2,org.nzbhydra:shared,org.nzbhydra:mapping,org.nzbhydra:assertions
+
+ - name: "Create docker network"
+ run: docker network create systemtest
+
+ - name: "Copy v1Migration docker data"
+ run: |
+ mkdir -p /tmp/hydra/v1MigrationDataFolder
+ cp -R tests/system/instanceData/v1Migration/* /tmp/hydra/v1MigrationDataFolder/
+
+ - name: "Run docker compose"
+ run: |
+ cd docker
+ docker-compose up -d
+
+ - name: "Wait for healthy containers"
+ run: |
+ docker ps
+ sleep 10
+ docker ps
+ sleep 10
+ docker ps
+ echo "Core container mounts:"
+ docker container inspect -f '{{ .Mounts}}' core
+ echo "v1Migration container mounts:"
+ docker container inspect -f '{{ .Mounts}}' v1Migration
+
+ - name: "Run tests"
+ run: mvn --batch-mode test -pl org.nzbhydra.tests:system -DtrimStackTrace=false
+
+ - name: "Upload data folder artifact"
+ uses: actions/upload-artifact@v3
+ if: always()
+ with:
+ name: data
+ path: /tmp/hydra
+
+ - name: "Create test Report"
+ uses: dorny/test-reporter@v1
+ if: always()
+ continue-on-error: true
+ with:
+ name: System test report ${{ matrix.test.name }}
+ path: "**/surefire-reports/*.xml"
+ reporter: java-junit
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 000000000..181f0bc0d
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,19 @@
+name: Java CI
+
+on: [ workflow_dispatch ]
+
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v3
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'adopt'
+ cache: 'maven'
+ - name: Test with Maven
+ run: mvn --batch-mode --update-snapshots verify
diff --git a/.gitignore b/.gitignore
index b522f368c..b71a831e8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,9 +19,11 @@ pom.xml.versionsBackup
javacore*
heapdump*
/ui
+*token*
!/.idea/compiler.xml
!/.idea/vcs.xml
!/.idea/misc.xml
misc/rsyncToServers.sh
/nzbhydra.yml
+/results
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
index b3c907d59..a184642c9 100644
--- a/.idea/compiler.xml
+++ b/.idea/compiler.xml
@@ -22,7 +22,9 @@
-
+
+
+
@@ -31,7 +33,6 @@
+
-
+
\ No newline at end of file
diff --git a/.run/NzbHydraNativeEntrypoint.run.xml b/.run/NzbHydraNativeEntrypoint.run.xml
new file mode 100644
index 000000000..210d296a2
--- /dev/null
+++ b/.run/NzbHydraNativeEntrypoint.run.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.snyk b/.snyk
new file mode 100644
index 000000000..7f6d55440
--- /dev/null
+++ b/.snyk
@@ -0,0 +1,9 @@
+# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.
+version: v1.25.0
+ignore:
+ SNYK-JAVA-ORGYAML-3152153:
+ - '*':
+ reason: no exploit
+ expires: 2030-04-01T00:00:00.000Z
+ created: 2023-01-20T10:45:42.937Z
+patch: {}
diff --git a/buildCore.cmd b/buildCore.cmd
new file mode 100644
index 000000000..98f37476a
--- /dev/null
+++ b/buildCore.cmd
@@ -0,0 +1,12 @@
+@echo off
+
+setlocal
+
+call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Auxiliary\Build\vcvarsall.bat" x64
+
+set path=c:\Programme\graalvm-ce-java17-22.2.0\bin\;%PATH%;c:\Programme\graalvm-ce-java17-22.2.0\bin\
+set java_home=c:\Programme\graalvm-ce-java17-22.2.0\
+set HYDRA_NATIVE_BUILD=true
+call mvn -pl org.nzbhydra:core -Pnative clean native:compile -DskipTests
+
+endlocal
diff --git a/changelog.md b/changelog.md
index 62ea77bc5..fb3719877 100644
--- a/changelog.md
+++ b/changelog.md
@@ -48,13 +48,15 @@
### v4.7.0 BETA (2022-09-18)
-**Feature** Use custom mappings to transform indexer result titles. Use this to clean up titles, add season or episode to it or whatever. See #794
+**Feature** Use custom customQueryAndTitleMappings to transform indexer result titles. Use this to clean up titles, add season or episode to it or whatever. See #794
-**Fix** Some of you have an instance running which is exposed to the internet, without any authentication method. I previously tried to recognize this by some heuristic which was a bit naive and caused a lot of false positives. NZBHydra will now periodically try to determine your public IP and actually check if the used port is open. This might still not always work (e.g. in when you're running it using a VPN in which case I guess you know what're doing. Ultimately it's up to you to get your shit together.
+**Fix** Some of you have an instance running which is exposed to the internet, without any authentication method. I previously tried to recognize this by some heuristic which was a bit naive and caused a lot of false positives. NZBHydra
+will now periodically try to determine your public IP and actually check if the used port is open. This might still not always work (e.g. in when you're running it using a VPN in which case I guess you know what're doing. Ultimately it's up
+to you to get your shit together.
**Fix** Only warn about settings violating indexers' rules if the indexers are actually enabled.
-**Fix** Fix saving config with custom mappings.
+**Fix** Fix saving config with custom customQueryAndTitleMappings.
@@ -74,7 +76,7 @@
**Feature** Automatically use NZB access and adding types required by certain indexers. See #784.
-**Feature** Add debug logging for category mapping.
+**Feature** Add debug logging for category customQueryAndTitleMapping.
@@ -100,13 +102,13 @@
**Fix** Add the current API hit to the number of reported API hits in response.
-**Fix** Fix name of logging marker "Custom mapping" (was "Config mapping").
+**Fix** Fix name of logging marker "Custom customQueryAndTitleMapping" (was "Config customQueryAndTitleMapping").
### v4.3.2 (2022-06-13)
-**Fix** Fix use of groups in custom search request mapping. See #700
+**Fix** Fix use of groups in custom search request customQueryAndTitleMapping. See #700
**Fix** Fix download of backup files. See #772
@@ -314,7 +316,8 @@
### v3.14.0 (2021-04-11)
-**Feature** Custom mapping for queries and titles. This allows you to customize / change the values used by external tools or returned by metadata providers like TVDB. See #700.
+**Feature** Custom customQueryAndTitleMapping for queries and titles. This allows you to customize / change the values used by external tools or returned by metadata providers like TVDB.
+See #700.
@@ -1366,7 +1369,8 @@
### v2.9.5 (2019-11-23)
-**Feature** I realised the indexer score is too complex to show in a chart and replaced it with a table, that shows more information. It will now contain the average uniqueness score, the number of unique downloads and the number of searches which resulted in a download and where an indexer was involved.
+**Feature** I realised the indexer score is too complex to show in a chart and replaced it with a table, that shows more information. It will now contain the average uniqueness score, the number of unique downloads and the number of
+searches which resulted in a download and where an indexer was involved.
@@ -1960,7 +1964,7 @@
### v2.1.7 (2018-12-30)
-**Fix** Fix/improve category mapping introduced in 2.1.6. Use custom newznab categories if none from the predefined range are provided.
+**Fix** Fix/improve category customQueryAndTitleMapping introduced in 2.1.6. Use custom newznab categories if none from the predefined range are provided.
@@ -1968,7 +1972,7 @@
**Fix** When uploading a backup file the UI didn't update to inform the user about the progress after the file was uploaded.
-**Fix** Improve category mapping for (torznab) indexers. Some use custom newznab category numbers (>9999) which could not be properly mapped to preconfigured categories.
+**Fix** Improve category customQueryAndTitleMapping for (torznab) indexers. Some use custom newznab category numbers (>9999) which could not be properly mapped to preconfigured categories.
@@ -2178,7 +2182,7 @@
**Fix** Restoring from web UI had no effect
-**Fix** Category mapping would sometimes not work for incoming searches
+**Fix** Category customQueryAndTitleMapping would sometimes not work for incoming searches
diff --git a/core/.gitignore b/core/.gitignore
index 0eb5d5f3e..2c8d4f939 100644
--- a/core/.gitignore
+++ b/core/.gitignore
@@ -22,4 +22,5 @@ sql notes.sql
/logback-access.xml
*.dmp
*.trc
-package-lock.json
\ No newline at end of file
+package-lock.json
+afile.txt
diff --git a/core/buildCoreAotOnly.cmd b/core/buildCoreAotOnly.cmd
new file mode 100644
index 000000000..c7e70d8ff
--- /dev/null
+++ b/core/buildCoreAotOnly.cmd
@@ -0,0 +1,12 @@
+@echo off
+
+setlocal
+
+call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Auxiliary\Build\vcvarsall.bat" x64
+
+set path=c:\Programme\graalvm-ce-java17-22.2.0\bin\;%PATH%;c:\Programme\graalvm-ce-java17-22.2.0\bin\
+set java_home=c:\Programme\graalvm-ce-java17-22.2.0\
+set HYDRA_NATIVE_BUILD=true
+mvn -Pnative clean package -DskipTests
+
+endlocal
diff --git a/core/pom.xml b/core/pom.xml
index 0bf087daa..593c4f267 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -5,7 +5,7 @@
org.nzbhydra
nzbhydra2
- 4.7.7-SNAPSHOT
+ 5.0.0-SNAPSHOT
core
@@ -14,6 +14,7 @@
${maven.build.timestamp}
yyyy-MM-dd HH:mm
+ 2.28.0.Final
@@ -33,6 +34,16 @@
+
+
+
+ org.graalvm.buildtools
+ native-maven-plugin
+ 0.9.19
+ true
+
+
+
org.apache.maven.plugins
@@ -46,6 +57,8 @@
TheOtherP
+ ${project.version}
+ ${maven.build.timestamp}
@@ -53,35 +66,30 @@
-
- org.apache.maven.plugins
- maven-compiler-plugin
- ${maven.compiler.plugin.version}
-
- 1.8
- 1.8
-
-
+
org.springframework.boot
spring-boot-maven-plugin
- ${spring.boot.version}
+ ${spring.boot.maven.version}
repackage
-
exec
+ org.nzbhydra.NzbHydra
- org.apache.maven.plugins
maven-surefire-plugin
- 3.0.0-M2
+ 2.22.2
+
+
+ maven-failsafe-plugin
+ 2.22.2
@@ -91,7 +99,7 @@
org.nzbhydra
mapping
- 4.7.7-SNAPSHOT
+ 5.0.0-SNAPSHOT
@@ -163,13 +171,18 @@
spring-boot-starter-actuator
${spring.boot.version}
+
+ org.glassfish.expressly
+ expressly
+ 5.0.0
+
com.h2database
h2
- 1.4.200
+ 2.1.214
com.github.marschall
@@ -197,7 +210,7 @@
com.fasterxml.jackson.core
jackson-annotations
- 2.13.0
+ ${jackson.version}
com.fasterxml.jackson.datatype
@@ -217,18 +230,19 @@
org.apache.logging.log4j
log4j-api
- 2.17.2
+ 2.19.0
org.projectlombok
lombok
${lombok.version}
- true
+ compile
+
com.google.guava
guava
- 20.0
+ 31.1-jre
commons-io
@@ -240,11 +254,6 @@
commons-lang3
${commons-lang3.version}
-
- com.uwetrottmann.tmdb2
- tmdb-java
- 1.6.0
-
com.github.briandilley.jsonrpc4j
jsonrpc4j
@@ -265,9 +274,9 @@
- com.vladsch.flexmark
- flexmark
- 0.34.12
+ org.commonmark
+ commonmark
+ 0.20.0
com.squareup.okhttp3
@@ -277,7 +286,7 @@
com.squareup.okhttp3
logging-interceptor
- 4.9.3
+ ${okhttp.version}
@@ -289,7 +298,7 @@
org.jsoup
jsoup
- 1.11.3
+ 1.15.3
net.jodah
@@ -301,54 +310,42 @@
URISchemeHandler
${uri-scheme-handler.version}
-
- com.sun.activation
- javax.activation
- 1.2.0
-
com.github.ben-manes.caffeine
caffeine
- 2.6.2
+ 3.1.2
- net.jodah
+ dev.failsafe
failsafe
- 1.1.1
+ 3.3.0
joda-time
joda-time
- 2.10.14
+ 2.12.2
compile
org.hibernate.validator
hibernate-validator
- 6.1.5.Final
+ 8.0.0.Final
org.javers
javers-core
- 6.6.5
+ ${javers-core.version}
-
-
-
- org.springframework.boot
- spring-boot-devtools
- ${spring.boot.version}
- true
-
-
- org.springdoc
- springdoc-openapi-ui
-
- 1.6.9
-
+
+
+
+
+
+
+
@@ -366,11 +363,12 @@
logback-access
${logback.version}
-
- net.rakugakibox.spring.boot
- logback-access-spring-boot-starter
- 2.7.1
-
+
+
+
+
+
+
net.logstash.logback
logstash-logback-encoder
@@ -385,17 +383,17 @@
org.slf4j
slf4j-api
- 1.7.36
+ 2.0.5
org.nzbhydra
sockslib
- 1.0.0
+ 3.0.0
net.sourceforge.htmlunit
htmlunit
- 2.64.0
+ 2.67.0
@@ -420,13 +418,13 @@
org.mockito
mockito-core
- 4.5.1
+ 4.8.1
test
org.hamcrest
hamcrest-library
- 1.3
+ 2.2
test
@@ -442,13 +440,139 @@
- junit
- junit
- ${junit.version}
+ org.jboss.forge.roaster
+ roaster-api
+ ${version.roaster}
+ test
+
+
+ org.jboss.forge.roaster
+ roaster-jdt
+ ${version.roaster}
test
+
+
+ dev
+
+
+
+ org.springframework.boot
+ spring-boot-devtools
+ ${spring.boot.devtools.version}
+ true
+
+
+
+
+ native
+
+ org.nzbhydra.NzbHydra
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+ org.nzbhydra.NzbHydra
+
+ paketobuildpacks/builder:tiny
+
+ true
+
+
+
+
+
+ process-aot
+
+ process-aot
+
+
+
+
+
+ org.graalvm.buildtools
+ native-maven-plugin
+
+
+ -H:+ReportExceptionStackTraces
+ -H:-DeadlockWatchdogExitOnTimeout
+ -H:DeadlockWatchdogInterval=0
+ --initialize-at-build-time=org.apache.commons.logging.LogFactoryService
+
+ ${project.build.outputDirectory}
+
+ true
+
+ 22.3
+
+
+
+ add-reachability-metadata
+
+ add-reachability-metadata
+
+
+
+
+
+
+
+
+
+ nativeTest
+
+
+
+
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ process-test-aot
+
+ process-test-aot
+
+
+
+
+
+ org.graalvm.buildtools
+ native-maven-plugin
+
+ ${project.build.outputDirectory}
+
+ true
+
+ 22.3
+
+
+
+ native-test
+
+ test
+
+
+
+
+
+
+
+
+
+
diff --git a/core/runTracingAgent.cmd b/core/runTracingAgent.cmd
new file mode 100644
index 000000000..fe7ef2114
--- /dev/null
+++ b/core/runTracingAgent.cmd
@@ -0,0 +1,13 @@
+@echo off
+
+setlocal
+
+call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Auxiliary\Build\vcvarsall.bat" x64
+
+set path=c:\Programme\graalvm-ce-java17-22.2.0\bin\;%PATH%;c:\Programme\graalvm-ce-java17-22.2.0\bin\
+set java_home=c:\Programme\graalvm-ce-java17-22.2.0\
+set HYDRA_NATIVE_BUILD=true
+cd target
+java -DspringAot=true -agentlib:native-image-agent=config-output-dir=hints -jar core-5.0.0-SNAPSHOT-exec.jar directstart
+
+endlocal
diff --git a/core/src/main/java/org/nzbhydra/DevEndpoint.java b/core/src/main/java/org/nzbhydra/DevEndpoint.java
index 018597082..d76819f3c 100644
--- a/core/src/main/java/org/nzbhydra/DevEndpoint.java
+++ b/core/src/main/java/org/nzbhydra/DevEndpoint.java
@@ -16,6 +16,8 @@
package org.nzbhydra;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
import org.nzbhydra.config.ConfigProvider;
import org.nzbhydra.config.indexer.IndexerConfig;
import org.nzbhydra.externaltools.AddRequest;
@@ -25,14 +27,14 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.http.MediaType;
+import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.annotation.Secured;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
-import javax.persistence.EntityManager;
-import javax.persistence.PersistenceContext;
import java.math.BigInteger;
import java.util.List;
@@ -58,6 +60,18 @@ public class DevEndpoint {
return resultList.get(0);
}
+ @Secured({"ROLE_ADMIN"})
+ @RequestMapping(value = "/dev/throwException", method = RequestMethod.GET)
+ public BigInteger throwException() throws Exception {
+ throw new RuntimeException("test");
+ }
+
+ @Secured({"ROLE_ADMIN"})
+ @RequestMapping(value = "/dev/throwAccessDeniedException", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE)
+ public BigInteger throwAccessDeniedException() throws Exception {
+ throw new AccessDeniedException("test");
+ }
+
@Secured({"ROLE_ADMIN"})
@Transactional
@RequestMapping(value = "/dev/deleteDanglingIndexersearches", method = RequestMethod.GET)
diff --git a/core/src/main/java/org/nzbhydra/ExceptionInfo.java b/core/src/main/java/org/nzbhydra/ExceptionInfo.java
index 05344fd67..9061625d3 100644
--- a/core/src/main/java/org/nzbhydra/ExceptionInfo.java
+++ b/core/src/main/java/org/nzbhydra/ExceptionInfo.java
@@ -1,10 +1,12 @@
package org.nzbhydra;
import lombok.Data;
+import org.nzbhydra.springnative.ReflectionMarker;
import java.time.Instant;
@Data
+@ReflectionMarker
public class ExceptionInfo {
private long timestamp;
private int status;
diff --git a/core/src/main/java/org/nzbhydra/InstanceCounter.java b/core/src/main/java/org/nzbhydra/InstanceCounter.java
index 4b19b4ee3..7d534b817 100644
--- a/core/src/main/java/org/nzbhydra/InstanceCounter.java
+++ b/core/src/main/java/org/nzbhydra/InstanceCounter.java
@@ -16,6 +16,8 @@
package org.nzbhydra;
+import jakarta.annotation.PostConstruct;
+import org.nzbhydra.config.BaseConfigHandler;
import org.nzbhydra.config.ConfigProvider;
import org.nzbhydra.webaccess.HydraOkHttp3ClientHttpRequestFactory;
import org.slf4j.Logger;
@@ -26,7 +28,6 @@ import org.springframework.http.client.ClientHttpRequest;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.stereotype.Component;
-import javax.annotation.PostConstruct;
import java.io.IOException;
import java.net.URI;
@@ -42,6 +43,8 @@ public class InstanceCounter {
@Autowired
private ConfigProvider configProvider;
+ @Autowired
+ private BaseConfigHandler baseConfigHandler;
@PostConstruct
public void downloadInstanceCounter() {
@@ -51,7 +54,7 @@ public class InstanceCounter {
if (response.getStatusCode().is2xxSuccessful()) {
logger.info("Instance counted");
configProvider.getBaseConfig().getMain().setInstanceCounterDownloaded(true);
- configProvider.getBaseConfig().save(false);
+ baseConfigHandler.save(false);
} else {
logger.error("Unable to count instance. Response: " + response.getStatusText());
}
diff --git a/core/src/main/java/org/nzbhydra/Markdown.java b/core/src/main/java/org/nzbhydra/Markdown.java
index 754086ed9..6dc4b1434 100644
--- a/core/src/main/java/org/nzbhydra/Markdown.java
+++ b/core/src/main/java/org/nzbhydra/Markdown.java
@@ -1,17 +1,16 @@
package org.nzbhydra;
-import com.vladsch.flexmark.ast.Node;
-import com.vladsch.flexmark.html.HtmlRenderer;
-import com.vladsch.flexmark.parser.Parser;
-import com.vladsch.flexmark.util.options.MutableDataSet;
+
+import org.commonmark.node.Node;
+import org.commonmark.parser.Parser;
+import org.commonmark.renderer.html.HtmlRenderer;
public class Markdown {
public static String renderMarkdownAsHtml(String markdown) {
- MutableDataSet options = new MutableDataSet();
- Parser parser = Parser.builder(options).build();
- HtmlRenderer renderer = HtmlRenderer.builder(options).build();
+ Parser parser = Parser.builder().build();
Node document = parser.parse(markdown);
+ HtmlRenderer renderer = HtmlRenderer.builder().build();
return renderer.render(document);
}
diff --git a/core/src/main/java/org/nzbhydra/NativeHints.java b/core/src/main/java/org/nzbhydra/NativeHints.java
new file mode 100644
index 000000000..042abff9a
--- /dev/null
+++ b/core/src/main/java/org/nzbhydra/NativeHints.java
@@ -0,0 +1,69 @@
+/*
+ * (C) Copyright 2023 TheOtherP (theotherp@posteo.net)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.nzbhydra;
+
+import org.commonmark.renderer.html.HtmlRenderer;
+import org.nzbhydra.config.migration.ConfigMigrationStep;
+import org.nzbhydra.springnative.ReflectionMarker;
+import org.reflections.Reflections;
+import org.reflections.scanners.Scanners;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.aot.hint.ExecutableMode;
+import org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Set;
+
+public class NativeHints implements RuntimeHintsRegistrar {
+
+ private static final Logger logger = LoggerFactory.getLogger(NativeHints.class);
+
+
+ @Override
+ public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
+ logger.info("Registering native hints");
+
+ hints.resources().registerResourceBundle("joptsimple.ExceptionMessages");
+
+
+ final Set> classes = getClassesToRegister();
+ classes.add(HashSet.class);
+ classes.add(ArrayList.class);
+ classes.add(HtmlRenderer.class);
+ for (Class> clazz : classes) {
+ hints.reflection().registerType(clazz, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS);
+ for (Method method : clazz.getDeclaredMethods()) {
+ logger.info("Registering " + method + " for reflection");
+ hints.reflection().registerMethod(method, ExecutableMode.INVOKE);
+ }
+ }
+
+ }
+
+ private static Set> getClassesToRegister() {
+ final Reflections reflections = new Reflections("org.nzbhydra", Scanners.TypesAnnotated, Scanners.SubTypes);
+ final Set> classes = reflections.getTypesAnnotatedWith(ReflectionMarker.class);
+ classes.addAll(reflections.getSubTypesOf(ConfigMigrationStep.class));
+ return classes;
+ }
+
+}
diff --git a/core/src/main/java/org/nzbhydra/NzbHydra.java b/core/src/main/java/org/nzbhydra/NzbHydra.java
index a8f4cc923..69919a4d4 100644
--- a/core/src/main/java/org/nzbhydra/NzbHydra.java
+++ b/core/src/main/java/org/nzbhydra/NzbHydra.java
@@ -2,19 +2,21 @@ package org.nzbhydra;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.google.common.base.Strings;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
import joptsimple.OptionException;
import joptsimple.OptionParser;
import joptsimple.OptionSet;
+import org.apache.commons.lang3.tuple.Pair;
import org.nzbhydra.config.BaseConfig;
+import org.nzbhydra.config.BaseConfigHandler;
import org.nzbhydra.config.ConfigProvider;
import org.nzbhydra.config.ConfigReaderWriter;
import org.nzbhydra.config.migration.ConfigMigration;
-import org.nzbhydra.database.DatabaseRecreation;
import org.nzbhydra.debuginfos.DebugInfosProvider;
import org.nzbhydra.genericstorage.GenericStorage;
import org.nzbhydra.logging.LoggingMarkers;
import org.nzbhydra.misc.BrowserOpener;
-import org.nzbhydra.update.UpdateManager;
import org.nzbhydra.web.UrlCalculator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -33,31 +35,26 @@ import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.ImportRuntimeHints;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.web.bind.annotation.RestController;
import org.yaml.snakeyaml.error.YAMLException;
-import javax.annotation.PostConstruct;
-import javax.annotation.PreDestroy;
-import javax.swing.*;
-import java.awt.GraphicsEnvironment;
-import java.awt.HeadlessException;
import java.io.File;
-import java.io.FileOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.net.URI;
+import java.nio.file.Files;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Map;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
+@ImportRuntimeHints(NativeHints.class)
@Configuration(proxyBeanMethods = false)
@EnableAutoConfiguration(exclude = {
- AopAutoConfiguration.class, org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration.class})
+ AopAutoConfiguration.class, org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration.class})
@ComponentScan
@RestController
@EnableCaching
@@ -73,7 +70,7 @@ public class NzbHydra {
private static String dataFolder = null;
private static boolean wasRestarted = false;
private static boolean anySettingsOverwritten = false;
- private static ConfigReaderWriter configReaderWriter = new ConfigReaderWriter();
+ private static final ConfigReaderWriter CONFIG_READER_WRITER = new ConfigReaderWriter();
@Autowired
private ConfigProvider configProvider;
@@ -85,12 +82,29 @@ public class NzbHydra {
private GenericStorage genericStorage;
@Autowired
private ApplicationEventPublisher applicationEventPublisher;
+ @Autowired
+ private BaseConfigHandler baseConfigHandler;
+
+ @Autowired
+ private DebugInfosProvider debugInfosProvider;
+
public static void main(String[] args) throws Exception {
- LoggerFactory.getILoggerFactory();
+ if (isNativeBuild()) {
+ logger.warn("Running for native build");
+ String dataFolder = "./data";
+ NzbHydra.setDataFolder(dataFolder);
+ System.setProperty("nzbhydra.dataFolder", dataFolder);
+ System.setProperty("spring.datasource.url", "jdbc:h2:mem:testdb;NON_KEYWORDS=YEAR,DATA,KEY");
- checkJavaVersion();
+ setApplicationPropertiesFromConfig();
+
+ SpringApplication hydraApplication = new SpringApplication(NzbHydra.class);
+ applicationContext = hydraApplication.run(args);
+ logger.info("Native application returned");
+ return;
+ }
OptionParser parser = new OptionParser();
parser.accepts("datafolder", "Define path to main data folder. Must start with ./ for relative paths").withRequiredArg().defaultsTo("./data");
@@ -108,46 +122,22 @@ public class NzbHydra {
logger.error("Invalid startup options detected: {}", e.getMessage());
System.exit(1);
}
- if (System.getProperty("fromWrapper") == null && Arrays.stream(args).noneMatch(x -> x.equals("directstart"))) {
- logger.info("NZBHydra 2 must be started using the wrapper for restart and updates to work. If for some reason you need to start it from the JAR directly provide the command line argument \"directstart\"");
- } else if (options.has("help")) {
+
+ setDataFolder(options);
+
+ if (options.has("help")) {
parser.printHelpOn(System.out);
} else if (options.has("version")) {
- String version = new UpdateManager().getAllVersionChangesUpToCurrentVersion().get(0).getVersion();
- logger.info("NZBHydra 2 version: " + version);
+ System.out.println(DebugInfosProvider.getVersionAndBuildTimestamp().getLeft());
+ } else if (System.getProperty("fromWrapper") == null && Arrays.stream(args).noneMatch(x -> x.equals("directstart"))) {
+ logger.info("NZBHydra 2 must be started using the wrapper for restart and updates to work. If for some reason you need to start it from the JAR directly provide the command line argument \"directstart\"");
} else {
startup(args, options);
}
}
- private static void checkJavaVersion() {
- final String javaVersionString;
- int javaMajor = 0;
- try {
- javaVersionString = System.getProperty("java.version");
- final Matcher matcher = Pattern.compile("(?\\d+)(\\.(?\\d+)\\.(?\\d)+[\\-_\\w]*)?.*").matcher(javaVersionString);
- if (!matcher.find()) {
- logger.error("Unable to determine JAVA version from {}", javaVersionString);
- return;
- }
-
- javaMajor = Integer.parseInt(matcher.group("major"));
- int javaMinor = Integer.parseInt(matcher.group("minor"));
- int javaVersion = 0;
- if ((javaMajor == 1 && javaMinor == 8)) {
- return;
- }
- } catch (Exception e) {
- logger.error("Unable to determine java version", e);
- return;
- }
- if (javaMajor > 17) {
- throw new RuntimeException("Deteted Java version " + javaVersionString + ". Please use Java 8, 11, 15 or 17. Java 18 and above are not supported");
- }
- }
-
- protected static void startup(String[] args, OptionSet options) throws Exception {
+ private static void setDataFolder(OptionSet options) throws IOException {
if (options.has("datafolder")) {
dataFolder = (String) options.valueOf("datafolder");
} else {
@@ -155,6 +145,10 @@ public class NzbHydra {
}
File dataFolderFile = new File(dataFolder);
dataFolder = dataFolderFile.getCanonicalPath();
+ }
+
+ protected static void startup(String[] args, OptionSet options) throws Exception {
+ File dataFolderFile = new File(dataFolder);
//Check if we can write in the data folder. If not we can just quit now
if (!dataFolderFile.exists() && !dataFolderFile.mkdirs()) {
logger.error("Unable to read or write data folder {}", dataFolder);
@@ -185,17 +179,13 @@ public class NzbHydra {
SpringApplication hydraApplication = new SpringApplication(NzbHydra.class);
NzbHydra.originalArgs = args;
wasRestarted = Arrays.asList(args).contains("restarted");
- if (NzbHydra.isOsWindows() && !options.has("quiet") && !options.has("nobrowser")) {
- hydraApplication.setHeadless(false);
- } else {
- hydraApplication.setHeadless(true);
- }
-
- DatabaseRecreation.runDatabaseScript();
-
+ hydraApplication.setHeadless(true);
applicationContext = hydraApplication.run(args);
} catch (Exception e) {
- handleException(e);
+ //Is thrown by SpringApplicationAotProcessor
+ if (!(e instanceof SpringApplication.AbandonedRunException)) {
+ handleException(e);
+ }
}
}
@@ -204,7 +194,7 @@ public class NzbHydra {
* Sets all properties referenced in application.properties so that they can be resolved
*/
private static void setApplicationPropertiesFromConfig() throws IOException {
- BaseConfig baseConfig = configReaderWriter.loadSavedConfig();
+ BaseConfig baseConfig = CONFIG_READER_WRITER.loadSavedConfig();
setApplicationProperty("main.host", "MAIN_HOST", baseConfig.getMain().getHost());
setApplicationProperty("main.port", "MAIN_PORT", String.valueOf(baseConfig.getMain().getPort()));
setApplicationProperty("main.urlBase", "MAIN_URL_BASE", baseConfig.getMain().getUrlBase().orElse("/"));
@@ -230,9 +220,9 @@ public class NzbHydra {
File systemErrLogFile = new File(NzbHydra.getDataFolder(), "logs/system.err.log");
File systemOutLogFile = new File(NzbHydra.getDataFolder(), "logs/system.out.log");
logger.info("Enabling SSL debugging. Will write to {}", systemErrLogFile);
- System.setErr(new PrintStream(new FileOutputStream(systemErrLogFile)));
+ System.setErr(new PrintStream(Files.newOutputStream(systemErrLogFile.toPath())));
logger.info("Redirecting console output to system.out.log. You will not see any more log output in the console until you disable the HTTPS marker and restart NZBHydra");
- System.setOut(new PrintStream(new FileOutputStream(systemOutLogFile)));
+ System.setOut(new PrintStream(Files.newOutputStream(systemOutLogFile.toPath())));
}
}
}
@@ -245,11 +235,14 @@ public class NzbHydra {
}
private static void initializeAndValidateAndMigrateYamlFile(File yamlFile) throws IOException {
- configReaderWriter.initializeIfNeeded(yamlFile);
- configReaderWriter.validateExistingConfig();
- Map map = configReaderWriter.loadSavedConfigAsMap();
+ if (NzbHydra.isNativeBuild()) {
+ return;
+ }
+ CONFIG_READER_WRITER.initializeIfNeeded(yamlFile);
+ CONFIG_READER_WRITER.validateExistingConfig();
+ Map map = CONFIG_READER_WRITER.loadSavedConfigAsMap();
Map migrated = new ConfigMigration().migrate(map);
- configReaderWriter.save(migrated, yamlFile);
+ CONFIG_READER_WRITER.save(migrated, yamlFile);
}
private static void handleException(Exception e) throws Exception {
@@ -272,39 +265,19 @@ public class NzbHydra {
msg = "An unexpected error occurred during startup:\n" + e;
logger.error("An unexpected error occurred during startup", e);
}
- try {
- if (!GraphicsEnvironment.isHeadless() && isOsWindows()) {
- final String htmlMessage = "" + msg.replace("\n", "
") + "";
- JOptionPane.showMessageDialog(null, htmlMessage, "NZBHydra 2 error", JOptionPane.ERROR_MESSAGE);
- }
- } catch (HeadlessException e1) {
- logger.warn("Unable to show exception in message dialog: {}", e1.getMessage());
- }
+ logger.error("FATAL: " + msg, e);
+
//Rethrow so that spring exception handlers can handle this
throw e;
}
-
- @PostConstruct
- private void addTrayIconIfApplicable() {
- boolean isOsWindows = isOsWindows();
- if (isOsWindows) {
- logger.info("Adding windows system tray icon");
- try {
- new WindowsTrayIcon();
- } catch (Throwable e) {
- logger.error("Can't add a windows tray icon because running headless");
- }
- }
- }
-
public static boolean isOsWindows() {
String osName = System.getProperty("os.name");
return osName.toLowerCase().contains("windows");
}
@PostConstruct
- private void warnIfSettingsOverwritten() {
+ public void warnIfSettingsOverwritten() {
if (anySettingsOverwritten) {
logger.warn("Overwritten settings will be displayed with their original value in the config section of the GUI");
}
@@ -338,21 +311,26 @@ public class NzbHydra {
//I don't know why I have to do this but otherwise genericStorage is always empty
configProvider.getBaseConfig().setGenericStorage(new ConfigReaderWriter().loadSavedConfig().getGenericStorage());
- if (!genericStorage.get("FirstStart", LocalDateTime.class).isPresent()) {
+ final Pair versionAndBuildTimestamp = DebugInfosProvider.getVersionAndBuildTimestamp();
+ logger.info("Version: {}", versionAndBuildTimestamp.getLeft());
+ logger.info("Build timestamp: {}", versionAndBuildTimestamp.getRight());
+ if (genericStorage.get("FirstStart", LocalDateTime.class).isEmpty()) {
logger.info("First start of NZBHydra detected");
genericStorage.save("FirstStart", LocalDateTime.now());
- configProvider.getBaseConfig().save(false);
+ baseConfigHandler.save(false);
}
+
if (DebugInfosProvider.isRunInDocker()) {
logger.info("You seem to be running NZBHydra 2 in docker. You can access Hydra using your local address and the IP you provided");
- } else if (configProvider.getBaseConfig().getMain().isStartupBrowser() && !"true".equals(System.getProperty(BROWSER_DISABLED))) {
- if (wasRestarted) {
- logger.info("Not opening browser after restart");
- return;
- }
- browserOpener.openBrowser();
} else {
+ if (configProvider.getBaseConfig().getMain().isStartupBrowser() && !"true".equals(System.getProperty(BROWSER_DISABLED))) {
+ if (wasRestarted) {
+ logger.info("Not opening browser after restart");
+ return;
+ }
+ browserOpener.openBrowser();
+ }
URI uri = urlCalculator.getLocalBaseUriBuilder().build().toUri();
logger.info("You can access NZBHydra 2 in your browser via {}", uri);
}
@@ -363,16 +341,6 @@ public class NzbHydra {
@PreDestroy
public void destroy() {
- boolean isOsWindows = isOsWindows();
- if (isOsWindows) {
- logger.debug("Initiating removal of windows tray icon (if it exists)");
- try {
- WindowsTrayIcon.remove();
- } catch (Throwable e) {
- //An exception might be thrown while shutting down, ignore this
- }
- }
- applicationEventPublisher.publishEvent(new ShutdownEvent());
logger.info("Shutting down and using up to {}ms to compact database", configProvider.getBaseConfig().getMain().getDatabaseCompactTime());
}
@@ -382,5 +350,11 @@ public class NzbHydra {
return new CaffeineCacheManager("infos", "titles", "updates", "dev");
}
+ static void setDataFolder(String dataFolder) {
+ NzbHydra.dataFolder = dataFolder;
+ }
+ public static boolean isNativeBuild() {
+ return System.getenv("HYDRA_NATIVE_BUILD") != null;
+ }
}
diff --git a/core/src/main/java/org/nzbhydra/NzbHydraNativeEntrypoint.java b/core/src/main/java/org/nzbhydra/NzbHydraNativeEntrypoint.java
new file mode 100644
index 000000000..37ef2d4e3
--- /dev/null
+++ b/core/src/main/java/org/nzbhydra/NzbHydraNativeEntrypoint.java
@@ -0,0 +1,48 @@
+/*
+ * (C) Copyright 2022 TheOtherP (theotherp@posteo.net)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.nzbhydra;
+
+import jakarta.annotation.PostConstruct;
+import org.springframework.boot.SpringApplicationAotProcessor;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.aot.AbstractAotProcessor;
+import org.springframework.javapoet.ClassName;
+import org.springframework.util.Assert;
+import org.springframework.util.StringUtils;
+
+import java.nio.file.Paths;
+import java.util.Arrays;
+
+@Configuration
+public class NzbHydraNativeEntrypoint {
+
+ public static void main(String[] args) throws Exception {
+ int requiredArgs = 6;
+ Assert.isTrue(args.length >= requiredArgs, () -> "Usage: " + SpringApplicationAotProcessor.class.getName()
+ + " ");
+ Class> application = Class.forName(args[0]);
+ AbstractAotProcessor.Settings settings = AbstractAotProcessor.Settings.builder().sourceOutput(Paths.get(args[1])).resourceOutput(Paths.get(args[2]))
+ .classOutput(Paths.get(args[3])).groupId((StringUtils.hasText(args[4])) ? args[4] : "unspecified")
+ .artifactId(args[5]).build();
+ String[] applicationArgs = (args.length > requiredArgs) ? Arrays.copyOfRange(args, requiredArgs, args.length)
+ : new String[0];
+ final SpringApplicationAotProcessor processor = new SpringApplicationAotProcessor(application, settings, applicationArgs);
+ final ClassName process = processor.process();
+ System.exit(0);
+ }
+
+}
diff --git a/core/src/main/java/org/nzbhydra/WindowsTrayIcon.java b/core/src/main/java/org/nzbhydra/WindowsTrayIcon.java
deleted file mode 100644
index 799a7c53f..000000000
--- a/core/src/main/java/org/nzbhydra/WindowsTrayIcon.java
+++ /dev/null
@@ -1,123 +0,0 @@
-package org.nzbhydra;
-
-import org.nzbhydra.misc.BrowserOpener;
-import org.nzbhydra.systemcontrol.SystemControl;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.beans.BeansException;
-import org.springframework.context.ConfigurableApplicationContext;
-
-import javax.swing.*;
-import java.awt.AWTException;
-import java.awt.MenuItem;
-import java.awt.PopupMenu;
-import java.awt.SystemTray;
-import java.awt.TrayIcon;
-import java.awt.event.MouseEvent;
-import java.awt.event.MouseListener;
-
-//Mostly taken from https://stackoverflow.com/a/44452260
-public class WindowsTrayIcon extends TrayIcon {
-
- private static final Logger logger = LoggerFactory.getLogger(WindowsTrayIcon.class);
-
- private static final String IMAGE_PATH = "/nzbhydra.png";
- private static final String TOOLTIP = "NZBHydra 2";
- private PopupMenu popup;
- private static SystemTray tray;
- private static WindowsTrayIcon instance;
-
- public WindowsTrayIcon() {
- super(new ImageIcon(WindowsTrayIcon.class.getResource(IMAGE_PATH), TOOLTIP).getImage(), TOOLTIP);
- try {
- popup = new PopupMenu();
- tray = SystemTray.getSystemTray();
- instance = this;
- setup();
- } catch (AWTException e) {
- logger.error("Unable to create tray icon", e);
- }
- }
-
- private void setup() throws AWTException {
- MenuItem openBrowserItem = new MenuItem("Open web UI");
- popup.add(openBrowserItem);
- openBrowserItem.addActionListener(e -> {
- openBrowser();
- });
-
- MenuItem restartItem = new MenuItem("Restart");
- popup.add(restartItem);
- restartItem.addActionListener(e -> {
- ((ConfigurableApplicationContext) NzbHydra.getApplicationContext()).close();
- remove();
- System.exit(SystemControl.RESTART_RETURN_CODE);
- });
-
- MenuItem shutdownItem = new MenuItem("Shutdown");
- popup.add(shutdownItem);
- shutdownItem.addActionListener(e -> {
- try {
- ((ConfigurableApplicationContext) NzbHydra.getApplicationContext()).close();
- } catch (Exception e1) {
- logger.error("Error while closing application context, will shut down hard");
- } finally {
- remove();
- }
- System.exit(SystemControl.SHUTDOWN_RETURN_CODE);
- });
-
-
- setPopupMenu(popup);
- tray.add(this);
- instance.addMouseListener(new MouseListener() {
- @Override
- public void mouseClicked(MouseEvent e) {
- if (e.getClickCount() == 2) {
- openBrowser();
- }
- }
-
- @Override
- public void mousePressed(MouseEvent e) {
-
- }
-
- @Override
- public void mouseReleased(MouseEvent e) {
-
- }
-
- @Override
- public void mouseEntered(MouseEvent e) {
-
- }
-
- @Override
- public void mouseExited(MouseEvent e) {
-
- }
- });
- }
-
- private void openBrowser() {
- try {
- NzbHydra.getApplicationContext().getAutowireCapableBeanFactory().createBean(BrowserOpener.class).openBrowser();
- } catch (NullPointerException | IllegalStateException | BeansException e1) {
- logger.error("Unable to open browser. Process may not have started completely");
- }
- }
-
- public static void remove() {
- try {
- if (tray != null && instance != null) {
- logger.info("Removing tray icon");
- tray.remove(instance);
- tray = null;
- }
- } catch (Throwable e) {
- logger.error("Unable to remove tray icon", e);
- }
- }
-
-}
diff --git a/core/src/main/java/org/nzbhydra/api/CapsGenerator.java b/core/src/main/java/org/nzbhydra/api/CapsGenerator.java
index ccb62650e..72163f0f3 100644
--- a/core/src/main/java/org/nzbhydra/api/CapsGenerator.java
+++ b/core/src/main/java/org/nzbhydra/api/CapsGenerator.java
@@ -18,10 +18,12 @@ package org.nzbhydra.api;
import com.google.common.collect.Sets;
import org.nzbhydra.config.ConfigProvider;
+import org.nzbhydra.config.SearchSource;
import org.nzbhydra.config.SearchSourceRestriction;
import org.nzbhydra.config.category.Category;
import org.nzbhydra.config.indexer.IndexerConfig;
import org.nzbhydra.config.indexer.SearchModuleType;
+import org.nzbhydra.config.mediainfo.MediaIdType;
import org.nzbhydra.mapping.newznab.ActionAttribute;
import org.nzbhydra.mapping.newznab.NewznabResponse;
import org.nzbhydra.mapping.newznab.OutputType;
@@ -47,8 +49,6 @@ import org.nzbhydra.mapping.newznab.xml.caps.CapsXmlSearch;
import org.nzbhydra.mapping.newznab.xml.caps.CapsXmlSearching;
import org.nzbhydra.mapping.newznab.xml.caps.CapsXmlServer;
import org.nzbhydra.mediainfo.InfoProvider;
-import org.nzbhydra.mediainfo.MediaIdType;
-import org.nzbhydra.searching.searchrequests.SearchRequest;
import org.nzbhydra.update.UpdateManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
@@ -66,7 +66,6 @@ import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
-import java.util.stream.Collectors;
@Component
public class CapsGenerator {
@@ -174,7 +173,7 @@ public class CapsGenerator {
if (x.getState() != IndexerConfig.State.ENABLED && x.getState() != IndexerConfig.State.DISABLED_SYSTEM_TEMPORARY) {
return false;
}
- if (!x.getEnabledForSearchSource().meets(SearchRequest.SearchSource.API)) {
+ if (!SearchSource.API.meets(x.getEnabledForSearchSource())) {
//Indexer will not be picked for API searches
return false;
}
@@ -217,9 +216,9 @@ public class CapsGenerator {
}
for (Category category : configProvider.getBaseConfig().getCategoriesConfig().getCategories()) {
List subCategories = category.getNewznabCategories().stream().flatMap(Collection::stream).filter(x -> x % 1000 != 0)
- //Lower numbers first so that predefined category numbers take precedence over custom ones
- .sorted(Comparator.naturalOrder())
- .collect(Collectors.toList());
+ //Lower numbers first so that predefined category numbers take precedence over custom ones
+ .sorted(Comparator.naturalOrder())
+ .toList();
//Use lowest category first
for (Integer subCategory : subCategories) {
if (alreadyAdded.contains(category.getName())) {
diff --git a/core/src/main/java/org/nzbhydra/api/CategoryConverter.java b/core/src/main/java/org/nzbhydra/api/CategoryConverter.java
index a4ffc7178..caf6fc22f 100644
--- a/core/src/main/java/org/nzbhydra/api/CategoryConverter.java
+++ b/core/src/main/java/org/nzbhydra/api/CategoryConverter.java
@@ -1,13 +1,12 @@
package org.nzbhydra.api;
+import jakarta.persistence.AttributeConverter;
+import jakarta.persistence.Converter;
import org.nzbhydra.config.category.Category;
import org.nzbhydra.searching.CategoryProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
-import javax.persistence.AttributeConverter;
-import javax.persistence.Converter;
-
@Converter
@Component
diff --git a/core/src/main/java/org/nzbhydra/api/ExternalApi.java b/core/src/main/java/org/nzbhydra/api/ExternalApi.java
index 7f2461832..97acb75d9 100644
--- a/core/src/main/java/org/nzbhydra/api/ExternalApi.java
+++ b/core/src/main/java/org/nzbhydra/api/ExternalApi.java
@@ -8,7 +8,11 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import org.apache.catalina.connector.ClientAbortException;
import org.nzbhydra.config.ConfigProvider;
+import org.nzbhydra.config.SearchSource;
import org.nzbhydra.config.category.CategoriesConfig;
+import org.nzbhydra.config.downloading.DownloadType;
+import org.nzbhydra.config.mediainfo.MediaIdType;
+import org.nzbhydra.config.searching.SearchType;
import org.nzbhydra.downloading.DownloadResult;
import org.nzbhydra.downloading.FileHandler;
import org.nzbhydra.downloading.InvalidSearchResultIdException;
@@ -19,16 +23,13 @@ import org.nzbhydra.mapping.newznab.NewznabResponse;
import org.nzbhydra.mapping.newznab.OutputType;
import org.nzbhydra.mapping.newznab.xml.NewznabXmlError;
import org.nzbhydra.mediainfo.Imdb;
-import org.nzbhydra.mediainfo.MediaIdType;
import org.nzbhydra.searching.CategoryProvider;
-import org.nzbhydra.searching.CustomQueryAndTitleMapping;
+import org.nzbhydra.searching.CustomQueryAndTitleMappingHandler;
import org.nzbhydra.searching.SearchResult;
import org.nzbhydra.searching.Searcher;
-import org.nzbhydra.searching.dtoseventsenums.DownloadType;
-import org.nzbhydra.searching.dtoseventsenums.SearchType;
import org.nzbhydra.searching.searchrequests.SearchRequest;
-import org.nzbhydra.searching.searchrequests.SearchRequest.SearchSource;
import org.nzbhydra.searching.searchrequests.SearchRequestFactory;
+import org.nzbhydra.springnative.ReflectionMarker;
import org.nzbhydra.web.SessionStorage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -90,7 +91,7 @@ public class ExternalApi {
@Autowired
private MockSearch mockSearch;
@Autowired
- private CustomQueryAndTitleMapping customQueryAndTitleMapping;
+ private CustomQueryAndTitleMappingHandler customQueryAndTitleMappingHandler;
protected Clock clock = Clock.systemUTC();
private final Random random = new Random();
@@ -336,13 +337,14 @@ public class ExternalApi {
searchRequest.getInternalData().setIncludePasswords(true);
}
searchRequest = searchRequestFactory.extendWithSavedIdentifiers(searchRequest);
- searchRequest = customQueryAndTitleMapping.mapSearchRequest(searchRequest);
+ searchRequest = customQueryAndTitleMappingHandler.mapSearchRequest(searchRequest);
return searchRequest;
}
@Data
+@ReflectionMarker
@AllArgsConstructor
private static class CacheEntryValue {
private final NewznabParameters params;
diff --git a/core/src/main/java/org/nzbhydra/api/NewznabJsonTransformer.java b/core/src/main/java/org/nzbhydra/api/NewznabJsonTransformer.java
index 0f254737c..d14fed7a6 100644
--- a/core/src/main/java/org/nzbhydra/api/NewznabJsonTransformer.java
+++ b/core/src/main/java/org/nzbhydra/api/NewznabJsonTransformer.java
@@ -17,6 +17,7 @@
package org.nzbhydra.api;
import org.nzbhydra.config.ConfigProvider;
+import org.nzbhydra.config.downloading.DownloadType;
import org.nzbhydra.downloading.FileHandler;
import org.nzbhydra.mapping.newznab.NewznabResponse;
import org.nzbhydra.mapping.newznab.json.NewznabJsonChannel;
@@ -29,7 +30,6 @@ import org.nzbhydra.mapping.newznab.json.NewznabJsonItemAttributes;
import org.nzbhydra.mapping.newznab.json.NewznabJsonResponseAttributes;
import org.nzbhydra.mapping.newznab.json.NewznabJsonRoot;
import org.nzbhydra.searching.dtoseventsenums.SearchResultItem;
-import org.nzbhydra.searching.dtoseventsenums.SearchResultItem.DownloadType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
diff --git a/core/src/main/java/org/nzbhydra/api/NewznabXmlTransformer.java b/core/src/main/java/org/nzbhydra/api/NewznabXmlTransformer.java
index 1603c280f..33a8a7d0a 100644
--- a/core/src/main/java/org/nzbhydra/api/NewznabXmlTransformer.java
+++ b/core/src/main/java/org/nzbhydra/api/NewznabXmlTransformer.java
@@ -17,6 +17,7 @@
package org.nzbhydra.api;
import org.nzbhydra.config.ConfigProvider;
+import org.nzbhydra.config.downloading.DownloadType;
import org.nzbhydra.downloading.FileHandler;
import org.nzbhydra.mapping.newznab.NewznabResponse;
import org.nzbhydra.mapping.newznab.xml.NewznabAttribute;
@@ -27,7 +28,6 @@ import org.nzbhydra.mapping.newznab.xml.NewznabXmlItem;
import org.nzbhydra.mapping.newznab.xml.NewznabXmlResponse;
import org.nzbhydra.mapping.newznab.xml.NewznabXmlRoot;
import org.nzbhydra.searching.dtoseventsenums.SearchResultItem;
-import org.nzbhydra.searching.dtoseventsenums.SearchResultItem.DownloadType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
diff --git a/core/src/main/java/org/nzbhydra/api/stats/ApiStatsRequest.java b/core/src/main/java/org/nzbhydra/api/stats/ApiStatsRequest.java
index ba7acac3c..462bdf53d 100644
--- a/core/src/main/java/org/nzbhydra/api/stats/ApiStatsRequest.java
+++ b/core/src/main/java/org/nzbhydra/api/stats/ApiStatsRequest.java
@@ -18,8 +18,10 @@ package org.nzbhydra.api.stats;
import lombok.Data;
import org.nzbhydra.historystats.stats.StatsRequest;
+import org.nzbhydra.springnative.ReflectionMarker;
@Data
+@ReflectionMarker
public class ApiStatsRequest {
protected String apikey;
diff --git a/core/src/main/java/org/nzbhydra/auth/AsyncSupportFilter.java b/core/src/main/java/org/nzbhydra/auth/AsyncSupportFilter.java
index 0f9e103b0..1f8e03363 100644
--- a/core/src/main/java/org/nzbhydra/auth/AsyncSupportFilter.java
+++ b/core/src/main/java/org/nzbhydra/auth/AsyncSupportFilter.java
@@ -17,14 +17,14 @@
package org.nzbhydra.auth;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import org.apache.catalina.Globals;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
-import javax.servlet.FilterChain;
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
diff --git a/core/src/main/java/org/nzbhydra/auth/AuthAndAccessEventHandler.java b/core/src/main/java/org/nzbhydra/auth/AuthAndAccessEventHandler.java
index eba7eaaeb..8fc28b3f5 100644
--- a/core/src/main/java/org/nzbhydra/auth/AuthAndAccessEventHandler.java
+++ b/core/src/main/java/org/nzbhydra/auth/AuthAndAccessEventHandler.java
@@ -1,5 +1,8 @@
package org.nzbhydra.auth;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import org.nzbhydra.notifications.AuthFailureNotificationEvent;
import org.nzbhydra.web.SessionStorage;
import org.slf4j.Logger;
@@ -17,9 +20,6 @@ import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Controller
@@ -69,7 +69,7 @@ public class AuthAndAccessEventHandler extends AccessDeniedHandlerImpl {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
- logger.warn("Access denied to IP {}: {}", SessionStorage.IP.get(), accessDeniedException.getMessage());
+ logger.warn("Access denied to IP {}: {}. Request path: {}. Parameters: {}", SessionStorage.IP.get(), accessDeniedException.getMessage(), request.getContextPath(), request.getParameterMap());
attemptService.accessFailed(SessionStorage.IP.get());
super.handle(request, response, accessDeniedException);
}
diff --git a/core/src/main/java/org/nzbhydra/auth/AuthWeb.java b/core/src/main/java/org/nzbhydra/auth/AuthWeb.java
index 11b21bac9..49d4be5ed 100644
--- a/core/src/main/java/org/nzbhydra/auth/AuthWeb.java
+++ b/core/src/main/java/org/nzbhydra/auth/AuthWeb.java
@@ -1,5 +1,6 @@
package org.nzbhydra.auth;
+import jakarta.servlet.http.HttpSession;
import org.nzbhydra.web.BootstrappedDataTO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
@@ -10,7 +11,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
-import javax.servlet.http.HttpSession;
import java.security.Principal;
@RestController
diff --git a/core/src/main/java/org/nzbhydra/auth/ForwardedForRecognizingFilter.java b/core/src/main/java/org/nzbhydra/auth/ForwardedForRecognizingFilter.java
index d319e16ea..515585652 100644
--- a/core/src/main/java/org/nzbhydra/auth/ForwardedForRecognizingFilter.java
+++ b/core/src/main/java/org/nzbhydra/auth/ForwardedForRecognizingFilter.java
@@ -16,15 +16,15 @@
package org.nzbhydra.auth;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import org.nzbhydra.web.SessionStorage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.filter.OncePerRequestFilter;
-import javax.servlet.FilterChain;
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class ForwardedForRecognizingFilter extends OncePerRequestFilter {
diff --git a/core/src/main/java/org/nzbhydra/auth/HeaderAuthenticationFilter.java b/core/src/main/java/org/nzbhydra/auth/HeaderAuthenticationFilter.java
index 4bffdca55..4843a49db 100644
--- a/core/src/main/java/org/nzbhydra/auth/HeaderAuthenticationFilter.java
+++ b/core/src/main/java/org/nzbhydra/auth/HeaderAuthenticationFilter.java
@@ -17,24 +17,27 @@
package org.nzbhydra.auth;
import com.google.common.net.InetAddresses;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import org.nzbhydra.config.auth.AuthConfig;
import org.nzbhydra.web.SessionStorage;
import org.nzbhydra.webaccess.HydraOkHttp3ClientHttpRequestFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
-import javax.servlet.FilterChain;
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
+import java.util.Objects;
public class HeaderAuthenticationFilter extends BasicAuthenticationFilter {
@@ -43,14 +46,36 @@ public class HeaderAuthenticationFilter extends BasicAuthenticationFilter {
private final HydraUserDetailsManager userDetailsManager;
private AuthConfig authConfig;
+ private final String internalApiKey;
+
public HeaderAuthenticationFilter(AuthenticationManager authenticationManager, HydraUserDetailsManager userDetailsManager, AuthConfig authConfig) {
super(authenticationManager);
this.userDetailsManager = userDetailsManager;
this.authConfig = authConfig;
+ //Must be provided by wrapper
+ internalApiKey = System.getProperty("internalApiKey");
+ if (internalApiKey != null) {
+ logger.info("Using internal API key");
+ }
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
+ final String sentInternalApiKey = request.getParameterValues("internalApiKey") == null ? null : request.getParameterValues("internalApiKey")[0];
+ if (sentInternalApiKey != null) {
+ if (Objects.equals(sentInternalApiKey, internalApiKey)) {
+
+ final AnonymousAuthenticationToken token = new AnonymousAuthenticationToken("key", "internalApi", AuthorityUtils.createAuthorityList("ROLE_ADMIN"));
+ token.setDetails(new HydraWebAuthenticationDetails(request));
+ SecurityContextHolder.getContext().setAuthentication(token);
+ onSuccessfulAuthentication(request, response, token);
+ logger.debug("Authorized access to {} via internal API key", request.getRequestURI());
+ chain.doFilter(request, response);
+ return;
+ } else {
+ logger.warn("Invalid internal API key provided");
+ }
+ }
if (authConfig.getAuthHeader() == null || authConfig.getAuthHeaderIpRanges().isEmpty()) {
chain.doFilter(request, response);
return;
diff --git a/core/src/main/java/org/nzbhydra/auth/HydraAnonymousAuthenticationFilter.java b/core/src/main/java/org/nzbhydra/auth/HydraAnonymousAuthenticationFilter.java
index a5cd387bd..f954a7ce6 100644
--- a/core/src/main/java/org/nzbhydra/auth/HydraAnonymousAuthenticationFilter.java
+++ b/core/src/main/java/org/nzbhydra/auth/HydraAnonymousAuthenticationFilter.java
@@ -1,7 +1,12 @@
package org.nzbhydra.auth;
-import org.nzbhydra.config.BaseConfig;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import jakarta.servlet.http.HttpServletRequest;
import org.nzbhydra.config.ConfigChangedEvent;
+import org.nzbhydra.config.ConfigProvider;
import org.nzbhydra.config.auth.AuthConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -18,11 +23,6 @@ import org.springframework.security.web.authentication.WebAuthenticationDetailsS
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
-import javax.servlet.FilterChain;
-import javax.servlet.ServletException;
-import javax.servlet.ServletRequest;
-import javax.servlet.ServletResponse;
-import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@@ -43,9 +43,9 @@ public class HydraAnonymousAuthenticationFilter extends AnonymousAuthenticationF
//Disabled by default because just by existing it will be used for static resource accesses where spring security is disabled
private boolean enabled = false;
- public HydraAnonymousAuthenticationFilter(@Autowired BaseConfig baseConfig) {
+ public HydraAnonymousAuthenticationFilter(@Autowired ConfigProvider configProvider) {
super("anonymous", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
- updateAuthorities(baseConfig.getAuth());
+ updateAuthorities(configProvider.getBaseConfig().getAuth());
}
public void enable() {
diff --git a/core/src/main/java/org/nzbhydra/auth/HydraEmbeddedServletContainer.java b/core/src/main/java/org/nzbhydra/auth/HydraEmbeddedServletContainer.java
index 513e45f8e..5754817fc 100644
--- a/core/src/main/java/org/nzbhydra/auth/HydraEmbeddedServletContainer.java
+++ b/core/src/main/java/org/nzbhydra/auth/HydraEmbeddedServletContainer.java
@@ -16,6 +16,7 @@
package org.nzbhydra.auth;
+import jakarta.servlet.ServletException;
import org.apache.catalina.connector.Request;
import org.apache.catalina.connector.Response;
import org.apache.catalina.valves.ValveBase;
@@ -27,7 +28,6 @@ import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory;
import org.springframework.stereotype.Component;
-import javax.servlet.ServletException;
import java.io.IOException;
/**
@@ -41,10 +41,9 @@ public class HydraEmbeddedServletContainer implements WebServerFactoryCustomizer
@Override
public void customize(ConfigurableServletWebServerFactory factory) {
- if (!(factory instanceof TomcatServletWebServerFactory)) {
+ if (!(factory instanceof TomcatServletWebServerFactory containerFactory)) {
return; //Is the case in tests
}
- TomcatServletWebServerFactory containerFactory = (TomcatServletWebServerFactory) factory;
containerFactory.addContextValves(new ValveBase() {
@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
diff --git a/core/src/main/java/org/nzbhydra/auth/HydraGlobalMethodSecurityConfiguration.java b/core/src/main/java/org/nzbhydra/auth/HydraGlobalMethodSecurityConfiguration.java
index d89c0aa71..fc0e13a0e 100644
--- a/core/src/main/java/org/nzbhydra/auth/HydraGlobalMethodSecurityConfiguration.java
+++ b/core/src/main/java/org/nzbhydra/auth/HydraGlobalMethodSecurityConfiguration.java
@@ -16,7 +16,7 @@
package org.nzbhydra.auth;
-import org.nzbhydra.config.BaseConfig;
+import org.nzbhydra.config.ConfigProvider;
import org.nzbhydra.config.auth.AuthType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -40,14 +40,14 @@ public class HydraGlobalMethodSecurityConfiguration extends GlobalMethodSecurity
private static final Logger hydraLogger = LoggerFactory.getLogger(HydraGlobalMethodSecurityConfiguration.class);
@Autowired
- private BaseConfig baseConfig;
+ private ConfigProvider configProvider;
@Bean
public MethodSecurityMetadataSource methodSecurityMetadataSource() {
List sources = new ArrayList<>();
- if (baseConfig.getAuth().getAuthType() != AuthType.NONE) {
- hydraLogger.info("Enabling auth type " + baseConfig.getAuth().getAuthType());
+ if (configProvider.getBaseConfig().getAuth().getAuthType() != AuthType.NONE) {
+ hydraLogger.info("Enabling auth type " + configProvider.getBaseConfig().getAuth().getAuthType());
sources.add(new SecuredAnnotationSecurityMetadataSource());
}
diff --git a/core/src/main/java/org/nzbhydra/auth/HydraWebAuthenticationDetails.java b/core/src/main/java/org/nzbhydra/auth/HydraWebAuthenticationDetails.java
index b24bb7c3d..a1cb1a290 100644
--- a/core/src/main/java/org/nzbhydra/auth/HydraWebAuthenticationDetails.java
+++ b/core/src/main/java/org/nzbhydra/auth/HydraWebAuthenticationDetails.java
@@ -1,10 +1,9 @@
package org.nzbhydra.auth;
import com.google.common.base.Objects;
+import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.web.authentication.WebAuthenticationDetails;
-import javax.servlet.http.HttpServletRequest;
-
public class HydraWebAuthenticationDetails extends WebAuthenticationDetails {
private final String filteredIp;
@@ -39,10 +38,9 @@ public class HydraWebAuthenticationDetails extends WebAuthenticationDetails {
if (this == o) {
return true;
}
- if (!(o instanceof HydraWebAuthenticationDetails)) {
+ if (!(o instanceof HydraWebAuthenticationDetails that)) {
return false;
}
- HydraWebAuthenticationDetails that = (HydraWebAuthenticationDetails) o;
return Objects.equal(filteredIp, that.filteredIp);
}
diff --git a/core/src/main/java/org/nzbhydra/auth/LoginAndAccessAttemptService.java b/core/src/main/java/org/nzbhydra/auth/LoginAndAccessAttemptService.java
index 0f4d24eab..b568d1f5a 100644
--- a/core/src/main/java/org/nzbhydra/auth/LoginAndAccessAttemptService.java
+++ b/core/src/main/java/org/nzbhydra/auth/LoginAndAccessAttemptService.java
@@ -19,7 +19,7 @@ public class LoginAndAccessAttemptService {
private final LoadingCache attemptsCache;
public LoginAndAccessAttemptService() {
- attemptsCache = CacheBuilder.newBuilder().expireAfterWrite(1, TimeUnit.DAYS).build(new CacheLoader() {
+ attemptsCache = CacheBuilder.newBuilder().expireAfterWrite(1, TimeUnit.DAYS).build(new CacheLoader<>() {
@Override
public Integer load(String key) throws Exception {
return 0;
diff --git a/core/src/main/java/org/nzbhydra/auth/PersistentLoginsEntity.java b/core/src/main/java/org/nzbhydra/auth/PersistentLoginsEntity.java
index 8c5d1dab8..0188bdd2a 100644
--- a/core/src/main/java/org/nzbhydra/auth/PersistentLoginsEntity.java
+++ b/core/src/main/java/org/nzbhydra/auth/PersistentLoginsEntity.java
@@ -1,12 +1,13 @@
package org.nzbhydra.auth;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import jakarta.validation.constraints.NotNull;
import lombok.Data;
+import org.nzbhydra.springnative.ReflectionMarker;
-import javax.persistence.Column;
-import javax.persistence.Entity;
-import javax.persistence.Id;
-import javax.persistence.Table;
-import javax.validation.constraints.NotNull;
import java.sql.Timestamp;
/**
@@ -16,7 +17,8 @@ import java.sql.Timestamp;
@Entity
@Table(name = "persistent_logins")
@Data
-public class PersistentLoginsEntity {
+@ReflectionMarker
+public final class PersistentLoginsEntity {
@Id
@NotNull
diff --git a/core/src/main/java/org/nzbhydra/auth/SecurityConfig.java b/core/src/main/java/org/nzbhydra/auth/SecurityConfig.java
index 36a38da76..6263ca419 100644
--- a/core/src/main/java/org/nzbhydra/auth/SecurityConfig.java
+++ b/core/src/main/java/org/nzbhydra/auth/SecurityConfig.java
@@ -1,5 +1,6 @@
package org.nzbhydra.auth;
+import jakarta.servlet.http.HttpServletRequest;
import org.nzbhydra.config.BaseConfig;
import org.nzbhydra.config.ConfigChangedEvent;
import org.nzbhydra.config.ConfigProvider;
@@ -12,26 +13,25 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.AuthenticationManager;
-import org.springframework.security.config.BeanIds;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
-import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
-import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.channel.ChannelProcessingFilter;
import org.springframework.security.web.authentication.WebAuthenticationDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
+import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.firewall.DefaultHttpFirewall;
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.web.filter.ForwardedHeaderFilter;
-import javax.servlet.http.HttpServletRequest;
-
@Configuration
@Order
@EnableWebSecurity
-public class SecurityConfig extends WebSecurityConfigurerAdapter {
+public class SecurityConfig {
private static final Logger logger = LoggerFactory.getLogger(SecurityConfig.class);
private static final int SECONDS_PER_DAY = 60 * 60 * 24;
@@ -45,95 +45,122 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthAndAccessEventHandler authAndAccessEventHandler;
@Autowired
+ private UserDetailsService userDetailsService;
+ @Autowired
private AsyncSupportFilter asyncSupportFilter;
private HeaderAuthenticationFilter headerAuthenticationFilter;
- @Override
- protected void configure(HttpSecurity http) throws Exception {
+ @Bean
+ public SecurityFilterChain filterChain(HttpSecurity http, AuthenticationManager authenticationManager) throws Exception {
BaseConfig baseConfig = configProvider.getBaseConfig();
- if (configProvider.getBaseConfig().getMain().isUseCsrf()) {
+ boolean useCsrf = Boolean.parseBoolean(System.getProperty("main.useCsrf"));
+ if (configProvider.getBaseConfig().getMain().isUseCsrf() && useCsrf) {
CookieCsrfTokenRepository csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse();
csrfTokenRepository.setCookieName("HYDRA-XSRF-TOKEN");
- http.csrf().csrfTokenRepository(csrfTokenRepository);
+ //https://docs.spring.io/spring-security/reference/5.8/migration/servlet/exploits.html#_i_am_using_angularjs_or_another_javascript_framework
+ CsrfTokenRequestAttributeHandler requestHandler = new CsrfTokenRequestAttributeHandler();
+ requestHandler.setCsrfRequestAttributeName(null);
+ http.csrf()
+ .csrfTokenRepository(csrfTokenRepository)
+ .csrfTokenRequestHandler(requestHandler);
} else {
+ logger.info("CSRF is disabled");
http.csrf().disable();
}
- http.headers().httpStrictTransportSecurity().disable();
- http.headers().frameOptions().disable();
+ http.headers()
+ .httpStrictTransportSecurity().disable()
+ .frameOptions().disable();
if (baseConfig.getAuth().getAuthType() == AuthType.BASIC) {
http = http
- .authorizeRequests()
- .antMatchers("/internalapi/userinfos").permitAll()
- .and()
- .httpBasic()
- .authenticationDetailsSource(new WebAuthenticationDetailsSource() {
- @Override
- public WebAuthenticationDetails buildDetails(HttpServletRequest context) {
- return new HydraWebAuthenticationDetails(context);
- }
- })
- .and()
- .logout().logoutUrl("/logout").deleteCookies("remember-me")
- .and();
+ .httpBasic()
+ .authenticationDetailsSource(new WebAuthenticationDetailsSource() {
+ @Override
+ public WebAuthenticationDetails buildDetails(HttpServletRequest context) {
+ return new HydraWebAuthenticationDetails(context);
+ }
+ })
+ .and();
} else if (baseConfig.getAuth().getAuthType() == AuthType.FORM) {
http = http
- .authorizeRequests()
- .antMatchers("/internalapi/userinfos").permitAll()
- .and()
- .formLogin().loginPage("/login").loginProcessingUrl("/login").defaultSuccessUrl("/").permitAll()
- .authenticationDetailsSource(new WebAuthenticationDetailsSource() {
- @Override
- public WebAuthenticationDetails buildDetails(HttpServletRequest context) {
- return new HydraWebAuthenticationDetails(context);
- }
- })
- .and()
- .logout().permitAll().logoutUrl("/logout").logoutSuccessUrl("/loggedout").logoutSuccessUrl("/").deleteCookies("remember-me")
- .and();
+ .formLogin()
+ .loginPage("/login")
+ .loginProcessingUrl("/login")
+ .defaultSuccessUrl("/")
+ .permitAll()
+ .authenticationDetailsSource(new WebAuthenticationDetailsSource() {
+ @Override
+ public WebAuthenticationDetails buildDetails(HttpServletRequest context) {
+ return new HydraWebAuthenticationDetails(context);
+ }
+ })
+ .and();
}
if (baseConfig.getAuth().isAuthConfigured()) {
+ http = http
+ .authorizeHttpRequests()
+ .requestMatchers("/internalapi/")
+ .authenticated()
+ .requestMatchers("/websocket/")
+ .authenticated()
+ .requestMatchers("/actuator/**")
+ .hasRole("ADMIN")
+ .requestMatchers(new AntPathRequestMatcher("/static/**"))
+ .permitAll()
+ .anyRequest()
+// .authenticated() //Does not include anonymous
+ .hasAnyRole("ADMIN", "ANONYMOUS", "USER")
+ .and()
+ .logout()
+ .permitAll()
+ .logoutUrl("/logout")
+ .logoutSuccessUrl("/")
+ .deleteCookies("remember-me")
+ .invalidateHttpSession(true)
+ .clearAuthentication(true)
+ .and()
+ ;
enableAnonymousAccessIfConfigured(http);
+
if (baseConfig.getAuth().isRememberUsers()) {
int rememberMeValidityDays = configProvider.getBaseConfig().getAuth().getRememberMeValidityDays();
if (rememberMeValidityDays == 0) {
rememberMeValidityDays = 1000; //Can't be disabled, three years should be enough
}
http = http
- .rememberMe()
- .alwaysRemember(true)
- .tokenValiditySeconds(rememberMeValidityDays * SECONDS_PER_DAY)
- .userDetailsService(userDetailsService())
- .and();
+ .rememberMe()
+ .alwaysRemember(true)
+ .tokenValiditySeconds(rememberMeValidityDays * SECONDS_PER_DAY)
+ .userDetailsService(userDetailsService)
+ .and();
}
- http.authorizeRequests()
- .antMatchers("/actuator/**")
- .hasRole("ADMIN")
- .anyRequest().permitAll();
+
+ headerAuthenticationFilter = new HeaderAuthenticationFilter(authenticationManager, hydraUserDetailsManager, configProvider.getBaseConfig().getAuth());
+ http.addFilterAfter(headerAuthenticationFilter, BasicAuthenticationFilter.class);
+ http.addFilterAfter(asyncSupportFilter, BasicAuthenticationFilter.class);
+
+ } else {
+ http.authorizeHttpRequests().anyRequest().permitAll();
}
- headerAuthenticationFilter = new HeaderAuthenticationFilter(authenticationManager(), hydraUserDetailsManager, configProvider.getBaseConfig().getAuth());
+ http.exceptionHandling().accessDeniedHandler(authAndAccessEventHandler);
+
http.addFilterBefore(new ForwardedForRecognizingFilter(), ChannelProcessingFilter.class);
//We need to extract the original IP before it's removed and not retrievable anymore by the ForwardedHeaderFilter
http.addFilterAfter(new ForwardedHeaderFilter(), ForwardedForRecognizingFilter.class);
- http.addFilterAfter(headerAuthenticationFilter, BasicAuthenticationFilter.class);
- http.addFilterAfter(asyncSupportFilter, BasicAuthenticationFilter.class);
-
- http.exceptionHandling().accessDeniedHandler(authAndAccessEventHandler);
+ return http.build();
}
- @Override
- public void configure(WebSecurity web) {
- web.ignoring().antMatchers("/static/**");
- }
-
private void enableAnonymousAccessIfConfigured(HttpSecurity http) {
//Create an anonymous auth filter. If any of the areas are not restricted the anonymous user will get its role
try {
if (!hydraAnonymousAuthenticationFilter.getAuthorities().isEmpty()) {
http.anonymous().authenticationFilter(hydraAnonymousAuthenticationFilter);
+
hydraAnonymousAuthenticationFilter.enable();
+
}
+
} catch (Exception e) {
logger.error("Unable to configure anonymous access", e);
}
@@ -146,21 +173,19 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
}
}
- @Bean(name = BeanIds.AUTHENTICATION_MANAGER)
- @Override
- public AuthenticationManager authenticationManagerBean() throws Exception {
- return super.authenticationManagerBean();
- }
-
@Bean
public DefaultHttpFirewall defaultHttpFirewall() {
//Allow duplicate trailing slashes which happen when behind a reverse proxy, e.g. proxy_pass http://127.0.0.1:5076/nzbhydra2/;
return new DefaultHttpFirewall();
}
- @Override
- public void configure(AuthenticationManagerBuilder auth) throws Exception {
- auth.userDetailsService(hydraUserDetailsManager);
+ @Bean
+ public AuthenticationManager authManager(HttpSecurity http)
+ throws Exception {
+ return http.getSharedObject(AuthenticationManagerBuilder.class)
+ .userDetailsService(hydraUserDetailsManager)
+ .and()
+ .build();
}
diff --git a/core/src/main/java/org/nzbhydra/backup/BackupAndRestore.java b/core/src/main/java/org/nzbhydra/backup/BackupAndRestore.java
index 5f852c778..3fc576a4d 100644
--- a/core/src/main/java/org/nzbhydra/backup/BackupAndRestore.java
+++ b/core/src/main/java/org/nzbhydra/backup/BackupAndRestore.java
@@ -1,26 +1,28 @@
package org.nzbhydra.backup;
import com.google.common.base.Stopwatch;
-import lombok.AllArgsConstructor;
-import lombok.Data;
-import lombok.NoArgsConstructor;
+import com.google.common.base.Throwables;
+import jakarta.annotation.PostConstruct;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import jakarta.persistence.Query;
import org.apache.commons.io.FileUtils;
+import org.h2.message.DbException;
import org.nzbhydra.GenericResponse;
import org.nzbhydra.NzbHydra;
import org.nzbhydra.config.ConfigProvider;
import org.nzbhydra.config.ConfigReaderWriter;
+import org.nzbhydra.genericstorage.GenericStorage;
import org.nzbhydra.logging.LoggingMarkers;
import org.nzbhydra.systemcontrol.SystemControl;
import org.nzbhydra.webaccess.HydraOkHttp3ClientHttpRequestFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.aot.hint.annotation.Reflective;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
-import javax.annotation.PostConstruct;
-import javax.persistence.EntityManager;
-import javax.persistence.PersistenceContext;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
@@ -36,7 +38,6 @@ import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.spi.FileSystemProvider;
-import java.time.Instant;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
@@ -64,6 +65,9 @@ public class BackupAndRestore {
private HydraOkHttp3ClientHttpRequestFactory requestFactory;
@Autowired
private SystemControl systemControl;
+
+ @Autowired
+ private GenericStorage genericStorage;
private final ConfigReaderWriter configReaderWriter = new ConfigReaderWriter();
@PostConstruct
@@ -77,7 +81,7 @@ public class BackupAndRestore {
}
@Transactional
- public File backup() throws Exception {
+ public File backup(boolean triggeredByUsed) throws Exception {
Stopwatch stopwatch = Stopwatch.createStarted();
File backupFolder = getBackupFolder();
if (!backupFolder.exists()) {
@@ -92,7 +96,10 @@ public class BackupAndRestore {
logger.info("Creating backup");
File backupZip = new File(backupFolder, "nzbhydra-" + LocalDateTime.now().format(DATE_PATTERN) + ".zip");
- backupDatabase(backupZip);
+ backupDatabase(backupZip, triggeredByUsed);
+ if (!backupZip.exists()) {
+ throw new RuntimeException("Export to file " + backupZip + " was not executed by database");
+ }
Map env = new HashMap<>();
env.put("create", "true");
//We use the jar filesystem so we can add files to the existing ZIP
@@ -117,8 +124,8 @@ public class BackupAndRestore {
logger.info("Deleting old backups if any exist");
Integer backupMaxAgeInWeeks = configProvider.getBaseConfig().getMain().getDeleteBackupsAfterWeeks().get();
File[] zips = backupFolder.listFiles((dir, name) -> name != null && name.startsWith("nzbhydra") && name.endsWith(".zip"));
-
if (zips != null) {
+ Map fileToBackupTime = new HashMap<>();
for (File zip : zips) {
Matcher matcher = FILE_PATTERN.matcher(zip.getName());
if (!matcher.matches()) {
@@ -126,8 +133,20 @@ public class BackupAndRestore {
continue;
}
LocalDateTime backupDate = LocalDateTime.from(DATE_PATTERN.parse(matcher.group(1)));
- if (backupDate.isBefore(LocalDateTime.now().minusSeconds(60 * 60 * 24 * 7 * backupMaxAgeInWeeks))) {
- logger.info("Deleting backup file {} because it's older than {} weeks", zip, backupMaxAgeInWeeks);
+ fileToBackupTime.put(zip, backupDate);
+ }
+ for (File zip : zips) {
+ if (!fileToBackupTime.containsKey(zip)) {
+ continue;
+ }
+ LocalDateTime backupDate = fileToBackupTime.get(zip);
+ if (backupDate.isBefore(LocalDateTime.now().minusSeconds(60L * 60 * 24 * 7 * backupMaxAgeInWeeks))) {
+ final boolean successfulNewerBackup = fileToBackupTime.entrySet().stream().anyMatch(x -> x.getKey() != zip && x.getValue().isAfter(backupDate));
+ if (!successfulNewerBackup) {
+ logger.warn("No successful backup was made after the creation of {}. Will not delete it.", zip.getAbsolutePath());
+ continue;
+ }
+ logger.info("Deleting backup file {} because it's older than {} weeks and a newer successful backup exists", zip, backupMaxAgeInWeeks);
boolean deleted = zip.delete();
if (!deleted) {
logger.warn("Unable to delete old backup file {}", zip.getName());
@@ -138,6 +157,7 @@ public class BackupAndRestore {
}
}
+ @Reflective
protected File getBackupFolder() {
final String backupFolder = configProvider.getBaseConfig().getMain().getBackupFolder();
if (backupFolder.contains(File.separator)) {
@@ -164,11 +184,31 @@ public class BackupAndRestore {
}
- private void backupDatabase(File targetFile) {
- String formattedFilepath = targetFile.getAbsolutePath().replace("\\", "/");
- logger.info("Backing up database to " + formattedFilepath);
- entityManager.createNativeQuery("BACKUP TO '" + formattedFilepath + "';").executeUpdate();
- logger.debug("Wrote database backup files to {}", targetFile.getAbsolutePath());
+ private void backupDatabase(File targetFile, boolean triggeredByUsed) {
+ final String tempPath;
+ try {
+ tempPath = Files.createTempFile("nzbhydra", "zip").toFile().getAbsolutePath().replace("\\", "/");
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ try {
+ String formattedFilepath = targetFile.getAbsolutePath().replace("\\", "/");
+ logger.info("Backing up database to " + formattedFilepath);
+ //Write a script to ensure that the backed up database is actually valid
+ final Query nativeQuery = entityManager.createNativeQuery("SCRIPT TO '%s';".formatted(tempPath));
+ //If the database is corrupted this command will back it up without exception
+ entityManager.createNativeQuery("BACKUP TO '" + formattedFilepath + "';").executeUpdate();
+ final List resultList = nativeQuery.getResultList();
+ logger.debug("Wrote database backup data to {}", targetFile.getAbsolutePath());
+ } catch (Exception e) {
+ logger.info("Deleting invalid backup file {}", targetFile);
+ targetFile.delete();
+ if (!triggeredByUsed) {
+ final String dbExceptionMessage = Throwables.getCausalChain(e).stream().filter(x -> x instanceof DbException).findFirst().map(Throwable::getMessage).orElse(null);
+ genericStorage.save("FAILED_BACKUP", new FailedBackupData(dbExceptionMessage));
+ }
+ throw e;
+ }
}
private void backupCertificates(FileSystem fileSystem) throws IOException {
@@ -205,8 +245,8 @@ public class BackupAndRestore {
try {
File tempFile = File.createTempFile("nzbhydra-restore", ".zip");
FileUtils.copyInputStreamToFile(inputStream, tempFile);
- restoreFromFile(tempFile);
tempFile.deleteOnExit();
+ restoreFromFile(tempFile);
return GenericResponse.ok();
} catch (Exception e) {
logger.error("Error while restoring", e);
@@ -265,12 +305,5 @@ public class BackupAndRestore {
}
}
- @Data
- @AllArgsConstructor
- @NoArgsConstructor
- public static class BackupEntry {
- private String filename;
- private Instant creationDate;
- }
}
diff --git a/core/src/main/java/org/nzbhydra/backup/BackupData.java b/core/src/main/java/org/nzbhydra/backup/BackupData.java
index da93c5e6e..829947405 100644
--- a/core/src/main/java/org/nzbhydra/backup/BackupData.java
+++ b/core/src/main/java/org/nzbhydra/backup/BackupData.java
@@ -1,11 +1,12 @@
package org.nzbhydra.backup;
-import lombok.Data;
+import org.nzbhydra.springnative.ReflectionMarker;
import java.io.Serializable;
import java.time.LocalDateTime;
-@Data
+
+@ReflectionMarker
public class BackupData implements Serializable {
protected LocalDateTime lastBackup;
@@ -18,5 +19,11 @@ public class BackupData implements Serializable {
this.lastBackup = LocalDateTime.now();
}
+ public LocalDateTime getLastBackup() {
+ return lastBackup;
+ }
+ public void setLastBackup(LocalDateTime lastBackup) {
+ this.lastBackup = lastBackup;
+ }
}
diff --git a/core/src/main/java/org/nzbhydra/backup/BackupTask.java b/core/src/main/java/org/nzbhydra/backup/BackupTask.java
index eb7142cdb..1e430d9bc 100644
--- a/core/src/main/java/org/nzbhydra/backup/BackupTask.java
+++ b/core/src/main/java/org/nzbhydra/backup/BackupTask.java
@@ -39,7 +39,7 @@ public class BackupTask {
int backupEveryXDays = configProvider.getBaseConfig().getMain().getBackupEveryXDays().get();
Optional firstStartOptional = genericStorage.get("FirstStart", LocalDateTime.class);
- if (!firstStartOptional.isPresent()) {
+ if (firstStartOptional.isEmpty()) {
logger.debug("First start date time not set (for some reason), aborting backup");
return;
}
@@ -52,7 +52,7 @@ public class BackupTask {
Optional backupData = genericStorage.get(KEY, BackupData.class);
- if (!backupData.isPresent()) {
+ if (backupData.isEmpty()) {
logger.debug("Executing first backup: {} days since first start and backup is to be executed every {} days", daysSinceFirstStart, backupEveryXDays);
executeBackup();
return;
@@ -68,7 +68,7 @@ public class BackupTask {
private void executeBackup() {
try {
logger.info("Starting weekly backup");
- backupAndRestore.backup();
+ backupAndRestore.backup(false);
genericStorage.save(KEY, new BackupData(LocalDateTime.now(clock)));
} catch (Exception e) {
logger.error("An error occured while doing a background backup", e);
diff --git a/core/src/main/java/org/nzbhydra/backup/BackupWeb.java b/core/src/main/java/org/nzbhydra/backup/BackupWeb.java
index 843269dcb..ad3e1de98 100644
--- a/core/src/main/java/org/nzbhydra/backup/BackupWeb.java
+++ b/core/src/main/java/org/nzbhydra/backup/BackupWeb.java
@@ -1,7 +1,6 @@
package org.nzbhydra.backup;
import org.nzbhydra.GenericResponse;
-import org.nzbhydra.backup.BackupAndRestore.BackupEntry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -33,7 +32,7 @@ public class BackupWeb {
@Transactional
public Object backupAndDownload() throws Exception {
try {
- File backupFile = backup.backup();
+ File backupFile = backup.backup(true);
logger.debug("Sending contents of file {}", backupFile.getAbsolutePath());
return ResponseEntity
@@ -52,7 +51,7 @@ public class BackupWeb {
@Transactional
public GenericResponse backupOnly() throws Exception {
try {
- backup.backup();
+ backup.backup(true);
return GenericResponse.ok();
} catch (Exception e) {
logger.error("Error while creating backup", e);
diff --git a/core/src/main/java/org/nzbhydra/backup/FailedBackupData.java b/core/src/main/java/org/nzbhydra/backup/FailedBackupData.java
new file mode 100644
index 000000000..d85cfd301
--- /dev/null
+++ b/core/src/main/java/org/nzbhydra/backup/FailedBackupData.java
@@ -0,0 +1,45 @@
+package org.nzbhydra.backup;
+
+import org.nzbhydra.springnative.ReflectionMarker;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+
+@ReflectionMarker
+public class FailedBackupData implements Serializable {
+
+ private final LocalDateTime time = LocalDateTime.now();
+ private boolean shown;
+ private String message;
+
+
+ public FailedBackupData() {
+ }
+
+ public FailedBackupData(String message) {
+ this.message = message;
+ }
+
+ public boolean isShown() {
+ return shown;
+ }
+
+ public void setShown(boolean shown) {
+ this.shown = shown;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+
+ public LocalDateTime getTime() {
+ return time;
+ }
+
+
+}
diff --git a/core/src/main/java/org/nzbhydra/config/BaseConfig.java b/core/src/main/java/org/nzbhydra/config/BaseConfig.java
deleted file mode 100644
index fd0f0b213..000000000
--- a/core/src/main/java/org/nzbhydra/config/BaseConfig.java
+++ /dev/null
@@ -1,306 +0,0 @@
-package org.nzbhydra.config;
-
-import com.fasterxml.jackson.annotation.JsonIgnore;
-import com.google.common.base.Joiner;
-import lombok.AccessLevel;
-import lombok.Data;
-import lombok.EqualsAndHashCode;
-import lombok.Getter;
-import lombok.Setter;
-import lombok.ToString;
-import org.javers.core.metamodel.annotation.DiffIgnore;
-import org.nzbhydra.NzbHydra;
-import org.nzbhydra.ShutdownEvent;
-import org.nzbhydra.config.auth.AuthConfig;
-import org.nzbhydra.config.category.CategoriesConfig;
-import org.nzbhydra.config.downloading.DownloadingConfig;
-import org.nzbhydra.config.indexer.IndexerConfig;
-import org.nzbhydra.logging.LoggingMarkers;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.context.ApplicationEventPublisher;
-import org.springframework.context.event.EventListener;
-import org.springframework.stereotype.Component;
-
-import javax.annotation.PostConstruct;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Comparator;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.Timer;
-import java.util.TimerTask;
-import java.util.concurrent.locks.Lock;
-import java.util.concurrent.locks.ReentrantLock;
-import java.util.stream.Collectors;
-
-@Component
-@Data
-@EqualsAndHashCode(exclude = {"applicationEventPublisher"}, callSuper = false)
-public class BaseConfig extends ValidatingConfig {
-
- private static final Logger logger = LoggerFactory.getLogger(BaseConfig.class);
-
- public static boolean isProductive = true;
-
- @Autowired
- @Getter(AccessLevel.NONE)
- @Setter(AccessLevel.NONE)
- @JsonIgnore
- private ApplicationEventPublisher applicationEventPublisher;
- @JsonIgnore
- private boolean initialized = false;
- @JsonIgnore
- private ConfigReaderWriter configReaderWriter = new ConfigReaderWriter();
- private AuthConfig auth = new AuthConfig();
- private CategoriesConfig categoriesConfig = new CategoriesConfig();
- private DownloadingConfig downloading = new DownloadingConfig();
- @DiffIgnore
- private List indexers = new ArrayList<>();
- private MainConfig main = new MainConfig();
- private SearchingConfig searching = new SearchingConfig();
- private NotificationConfig notificationConfig = new NotificationConfig();
-
- @DiffIgnore
- private Map genericStorage = new HashMap<>();
-
- @JsonIgnore
- @Getter(AccessLevel.NONE)
- @Setter(AccessLevel.NONE)
- private Lock saveLock = new ReentrantLock();
- @JsonIgnore
- @Getter(AccessLevel.NONE)
- @Setter(AccessLevel.NONE)
- @ToString.Exclude
- private BaseConfig toSave;
- @JsonIgnore
- @Getter(AccessLevel.NONE)
- @Setter(AccessLevel.NONE)
- private TimerTask delayedSaveTimerTask;
-
-
- public void replace(BaseConfig newConfig) {
- replace(newConfig, true);
- }
-
- private void replace(BaseConfig newConfig, boolean fireConfigChangedEvent) {
- BaseConfig oldBaseConfig = configReaderWriter.getCopy(this);
-
- main = newConfig.getMain();
- categoriesConfig = newConfig.getCategoriesConfig();
- indexers = newConfig.getIndexers().stream().sorted(Comparator.comparing(IndexerConfig::getName)).collect(Collectors.toList());
- downloading = newConfig.getDownloading();
- searching = newConfig.getSearching();
- auth = newConfig.getAuth();
- genericStorage = newConfig.genericStorage;
- notificationConfig = newConfig.notificationConfig;
- if (fireConfigChangedEvent) {
- ConfigChangedEvent configChangedEvent = new ConfigChangedEvent(this, oldBaseConfig, this);
- applicationEventPublisher.publishEvent(configChangedEvent);
- }
- }
-
- public void save(boolean saveInstantly) {
- saveLock.lock();
- if (saveInstantly) {
- logger.debug(LoggingMarkers.CONFIG_READ_WRITE, "Saving instantly");
- configReaderWriter.save(this);
- toSave = null;
- } else {
- logger.debug(LoggingMarkers.CONFIG_READ_WRITE, "Delaying save");
- toSave = this;
- }
- saveLock.unlock();
- }
-
-
- @PostConstruct
- public void init() throws IOException {
- if (!isProductive) {
- //Don't overwrite settings from test code
- initialized = true;
- return;
- }
- if (initialized) {
- //In some cases a call to the server will attempt to restart everything, trying to initialize beans. This
- //method is called a second time and an empty / initial config is written
- logger.warn("Init method called again. This can only happen during a faulty shutdown");
- return;
- }
- logger.info("Using data folder {}", NzbHydra.getDataFolder());
- replace(configReaderWriter.loadSavedConfig(), false);
- if (main.getApiKey() == null) {
- initializeNewConfig();
- }
- //Always save config to keep it in sync with base config (remove obsolete settings and add new ones)
- configReaderWriter.save(this);
-
- delayedSaveTimerTask = new TimerTask() {
- @Override
- public void run() {
- saveToSave();
- }
- };
- Timer delayedSaveTimer = new Timer("delayedConfigSave", false);
- delayedSaveTimer.scheduleAtFixedRate(delayedSaveTimerTask, 10000, 10000);
-
- initialized = true;
- }
-
- public void load() throws IOException {
- replace(configReaderWriter.loadSavedConfig());
- }
-
-
- @EventListener
- public void onShutdown(ShutdownEvent event) {
- saveToSave();
- }
-
- private void saveToSave() {
- saveLock.lock();
- if (toSave != null) {
- logger.debug(LoggingMarkers.CONFIG_READ_WRITE, "Executing delayed save");
- configReaderWriter.save(toSave);
- toSave = null;
- }
- saveLock.unlock();
- }
-
- @Override
- public ConfigValidationResult validateConfig(BaseConfig oldConfig, BaseConfig newConfig, BaseConfig newBaseConfig) {
- ConfigValidationResult configValidationResult = new ConfigValidationResult();
-
- ConfigValidationResult authValidation = newConfig.getAuth().validateConfig(oldConfig, newConfig.getAuth(), newBaseConfig);
- configValidationResult.getErrorMessages().addAll(authValidation.getErrorMessages());
- configValidationResult.getWarningMessages().addAll(authValidation.getWarningMessages());
- configValidationResult.setRestartNeeded(configValidationResult.isRestartNeeded() || authValidation.isRestartNeeded());
-
- ConfigValidationResult categoriesValidation = newConfig.getCategoriesConfig().validateConfig(oldConfig, newConfig.getCategoriesConfig(), newBaseConfig);
- configValidationResult.getErrorMessages().addAll(categoriesValidation.getErrorMessages());
- configValidationResult.getWarningMessages().addAll(categoriesValidation.getWarningMessages());
- configValidationResult.setRestartNeeded(configValidationResult.isRestartNeeded() || categoriesValidation.isRestartNeeded());
-
- ConfigValidationResult mainValidation = newConfig.getMain().validateConfig(oldConfig, newConfig.getMain(), newBaseConfig);
- configValidationResult.getErrorMessages().addAll(mainValidation.getErrorMessages());
- configValidationResult.getWarningMessages().addAll(mainValidation.getWarningMessages());
- configValidationResult.setRestartNeeded(configValidationResult.isRestartNeeded() || mainValidation.isRestartNeeded());
-
- ConfigValidationResult searchingValidation = newConfig.getSearching().validateConfig(oldConfig, newConfig.getSearching(), newBaseConfig);
- configValidationResult.getErrorMessages().addAll(searchingValidation.getErrorMessages());
- configValidationResult.getWarningMessages().addAll(searchingValidation.getWarningMessages());
- configValidationResult.setRestartNeeded(configValidationResult.isRestartNeeded() || searchingValidation.isRestartNeeded());
-
- ConfigValidationResult downloadingValidation = newConfig.getDownloading().validateConfig(oldConfig, newConfig.getDownloading(), newBaseConfig);
- configValidationResult.getErrorMessages().addAll(downloadingValidation.getErrorMessages());
- configValidationResult.getWarningMessages().addAll(downloadingValidation.getWarningMessages());
- configValidationResult.setRestartNeeded(configValidationResult.isRestartNeeded() || downloadingValidation.isRestartNeeded());
-
- for (IndexerConfig indexer : newConfig.getIndexers()) {
- ConfigValidationResult indexerValidation = indexer.validateConfig(oldConfig, indexer, newBaseConfig);
- configValidationResult.getErrorMessages().addAll(indexerValidation.getErrorMessages());
- configValidationResult.getWarningMessages().addAll(indexerValidation.getWarningMessages());
- configValidationResult.setRestartNeeded(configValidationResult.isRestartNeeded() || indexerValidation.isRestartNeeded());
- }
-
- validateIndexers(newConfig, configValidationResult);
- if (!configValidationResult.getErrorMessages().isEmpty()) {
- logger.warn("Config validation returned errors:\n" + Joiner.on("\n").join(configValidationResult.getErrorMessages()));
- }
- if (!configValidationResult.getWarningMessages().isEmpty()) {
- logger.warn("Config validation returned warnings:\n" + Joiner.on("\n").join(configValidationResult.getWarningMessages()));
- }
-
- if (configValidationResult.isRestartNeeded()) {
- logger.warn("Settings were changed that require a restart to become effective");
- }
-
- configValidationResult.setOk(configValidationResult.getErrorMessages().isEmpty());
-
- return configValidationResult;
- }
-
- private void validateIndexers(BaseConfig newConfig, ConfigValidationResult configValidationResult) {
- if (!newConfig.getIndexers().isEmpty()) {
- if (newConfig.getIndexers().stream().noneMatch(x -> x.getState() == IndexerConfig.State.ENABLED)) {
- configValidationResult.getWarningMessages().add("No indexers enabled. Searches will return empty results");
- } else if (newConfig.getIndexers().stream().allMatch(x -> x.getSupportedSearchIds().isEmpty())) {
- if (newConfig.getSearching().getGenerateQueries() == SearchSourceRestriction.NONE) {
- configValidationResult.getWarningMessages().add("No indexer found that supports search IDs. Without query generation searches using search IDs will return empty results.");
- } else if (newConfig.getSearching().getGenerateQueries() != SearchSourceRestriction.BOTH) {
- String name = newConfig.getSearching().getGenerateQueries() == SearchSourceRestriction.API ? "internal" : "API";
- configValidationResult.getWarningMessages().add("No indexer found that supports search IDs. Without query generation " + name + " searches using search IDs will return empty results.");
- }
- }
- Set indexerNames = new HashSet<>();
- Set duplicateIndexerNames = new HashSet<>();
-
- for (IndexerConfig indexer : newConfig.getIndexers()) {
- if (!indexerNames.add(indexer.getName())) {
- duplicateIndexerNames.add(indexer.getName());
- }
- }
- if (!duplicateIndexerNames.isEmpty()) {
- configValidationResult.getErrorMessages().add("Duplicate indexer names found: " + Joiner.on(", ").join(duplicateIndexerNames));
- }
-
-
- final Set> indexersSameHostAndApikey = new HashSet<>();
-
- for (IndexerConfig indexer : newConfig.getIndexers()) {
- final Set otherIndexersSameHostAndApiKey = newConfig.getIndexers().stream()
- .filter(x -> x != indexer)
- .filter(x -> IndexerConfig.isIndexerEquals(x, indexer))
- .map(IndexerConfig::getName)
- .collect(Collectors.toSet());
- if (!otherIndexersSameHostAndApiKey.isEmpty()) {
- otherIndexersSameHostAndApiKey.add(indexer.getName());
- if (indexersSameHostAndApikey.stream().noneMatch(x -> x.contains(indexer.getName()))) {
- indexersSameHostAndApikey.add(otherIndexersSameHostAndApiKey);
- final String message = "Found multiple indexers with same host and API key: " + Joiner.on(", ").join(otherIndexersSameHostAndApiKey);
- logger.warn(message);
- configValidationResult.getWarningMessages().add(message);
- }
- }
- }
-
- } else {
- configValidationResult.getWarningMessages().add("No indexers configured. You won't get any results");
- }
- }
-
- @Override
- public BaseConfig prepareForSaving(BaseConfig oldBaseConfig) {
- getCategoriesConfig().prepareForSaving(oldBaseConfig);
- getDownloading().prepareForSaving(oldBaseConfig);
- getSearching().prepareForSaving(oldBaseConfig);
- getMain().prepareForSaving(oldBaseConfig);
- getMain().getLogging().prepareForSaving(oldBaseConfig);
- getAuth().prepareForSaving(oldBaseConfig);
- getIndexers().forEach(indexerConfig -> indexerConfig.prepareForSaving(oldBaseConfig));
-
- return this;
- }
-
- @Override
- public BaseConfig updateAfterLoading() {
- getAuth().updateAfterLoading();
- return this;
- }
-
- @Override
- public BaseConfig initializeNewConfig() {
- getCategoriesConfig().initializeNewConfig();
- getDownloading().initializeNewConfig();
- getSearching().initializeNewConfig();
- getMain().initializeNewConfig();
- getAuth().initializeNewConfig();
- return this;
- }
-
-
-}
diff --git a/core/src/main/java/org/nzbhydra/config/BaseConfigHandler.java b/core/src/main/java/org/nzbhydra/config/BaseConfigHandler.java
new file mode 100644
index 000000000..277cf6f99
--- /dev/null
+++ b/core/src/main/java/org/nzbhydra/config/BaseConfigHandler.java
@@ -0,0 +1,147 @@
+/*
+ * (C) Copyright 2023 TheOtherP (theotherp@posteo.net)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.nzbhydra.config;
+
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import org.nzbhydra.NzbHydra;
+import org.nzbhydra.config.indexer.IndexerConfig;
+import org.nzbhydra.config.validation.BaseConfigValidator;
+import org.nzbhydra.logging.LoggingMarkers;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.util.Comparator;
+import java.util.Timer;
+import java.util.TimerTask;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.stream.Collectors;
+
+@Component
+public class BaseConfigHandler {
+
+ private static final Logger logger = LoggerFactory.getLogger(BaseConfigHandler.class);
+
+ private final ConfigReaderWriter configReaderWriter = new ConfigReaderWriter();
+
+ private final Lock saveLock = new ReentrantLock();
+
+ private BaseConfig toSave;
+
+ private TimerTask delayedSaveTimerTask;
+
+ public boolean initialized = false;
+
+ @Autowired
+ private ApplicationEventPublisher applicationEventPublisher;
+
+ @Autowired
+ private BaseConfig baseConfig;
+ @Autowired
+ private BaseConfigValidator baseConfigValidator;
+
+ @PostConstruct
+ public void init() throws IOException {
+
+ if (initialized) {
+ //In some cases a call to the server will attempt to restart everything, trying to initialize beans. This
+ //method is called a second time and an empty / initial config is written
+ logger.warn("Init method called again. This can only happen during a faulty shutdown");
+ return;
+ }
+ logger.info("Using data folder {}", NzbHydra.getDataFolder());
+ replace(configReaderWriter.loadSavedConfig(), false);
+ if (baseConfig.getMain().getApiKey() == null) {
+ baseConfigValidator.initializeNewConfig(baseConfig);
+ }
+ //Always save config to keep it in sync with base config (remove obsolete settings and add new ones)
+ configReaderWriter.save(baseConfig);
+
+ delayedSaveTimerTask = new TimerTask() {
+ @Override
+ public void run() {
+ saveToSave();
+ }
+ };
+ Timer delayedSaveTimer = new Timer("delayedConfigSave", false);
+ delayedSaveTimer.scheduleAtFixedRate(delayedSaveTimerTask, 10000, 10000);
+ initialized = true;
+ }
+
+ public void replace(BaseConfig newConfig) {
+ replace(newConfig, true);
+ }
+
+ public void replace(BaseConfig newConfig, boolean fireConfigChangedEvent) {
+ BaseConfig oldBaseConfig = configReaderWriter.getCopy(baseConfig);
+ baseConfig.setMain(newConfig.getMain());
+ baseConfig.setIndexers(newConfig.getIndexers().stream().sorted(Comparator.comparing(IndexerConfig::getName)).collect(Collectors.toList()));
+ baseConfig.setCategoriesConfig(newConfig.getCategoriesConfig());
+ baseConfig.setSearching(newConfig.getSearching());
+ baseConfig.setDownloading(newConfig.getDownloading());
+ baseConfig.setAuth(newConfig.getAuth());
+ baseConfig.setGenericStorage(newConfig.getGenericStorage());
+ baseConfig.setNotificationConfig(newConfig.getNotificationConfig());
+
+
+ if (fireConfigChangedEvent) {
+ ConfigChangedEvent configChangedEvent = new ConfigChangedEvent(this, oldBaseConfig, newConfig);
+ applicationEventPublisher.publishEvent(configChangedEvent);
+ }
+ }
+
+ public void save(boolean saveInstantly) {
+ saveLock.lock();
+ if (saveInstantly) {
+ logger.debug(LoggingMarkers.CONFIG_READ_WRITE, "Saving instantly");
+ configReaderWriter.save(baseConfig);
+ toSave = null;
+ } else {
+ logger.debug(LoggingMarkers.CONFIG_READ_WRITE, "Delaying save");
+ toSave = baseConfig;
+ }
+ saveLock.unlock();
+ }
+
+ public void load() throws IOException {
+ replace(configReaderWriter.loadSavedConfig());
+ }
+
+
+ @PreDestroy
+ public void onShutdown() {
+ saveToSave();
+ delayedSaveTimerTask.cancel();
+ }
+
+ private void saveToSave() {
+ saveLock.lock();
+ if (toSave != null) {
+ logger.debug(LoggingMarkers.CONFIG_READ_WRITE, "Executing delayed save");
+ configReaderWriter.save(toSave);
+ toSave = null;
+ }
+ saveLock.unlock();
+ }
+
+
+}
diff --git a/core/src/main/java/org/nzbhydra/config/ConfigProvider.java b/core/src/main/java/org/nzbhydra/config/ConfigProvider.java
index b7d53fadf..addcf3e5a 100644
--- a/core/src/main/java/org/nzbhydra/config/ConfigProvider.java
+++ b/core/src/main/java/org/nzbhydra/config/ConfigProvider.java
@@ -2,9 +2,12 @@ package org.nzbhydra.config;
import org.nzbhydra.config.indexer.IndexerConfig;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.DependsOn;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
+//BaseConfig must be initialized before we can provide it
+@DependsOn("baseConfigHandler")
@Component
public class ConfigProvider {
diff --git a/core/src/main/java/org/nzbhydra/config/ConfigReaderWriter.java b/core/src/main/java/org/nzbhydra/config/ConfigReaderWriter.java
index 59f5cf1ba..ea1101fcc 100644
--- a/core/src/main/java/org/nzbhydra/config/ConfigReaderWriter.java
+++ b/core/src/main/java/org/nzbhydra/config/ConfigReaderWriter.java
@@ -21,8 +21,10 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.google.common.base.Charsets;
import com.google.common.base.Stopwatch;
import com.google.common.base.Strings;
-import net.jodah.failsafe.Failsafe;
-import net.jodah.failsafe.RetryPolicy;
+import dev.failsafe.Failsafe;
+import dev.failsafe.RetryPolicy;
+import dev.failsafe.event.EventListener;
+import dev.failsafe.event.ExecutionCompletedEvent;
import org.apache.commons.io.IOUtils;
import org.nzbhydra.Jackson;
import org.nzbhydra.NzbHydra;
@@ -38,6 +40,7 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
+import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@@ -47,9 +50,9 @@ public class ConfigReaderWriter {
private static final Logger logger = LoggerFactory.getLogger(ConfigReaderWriter.class);
- public static final TypeReference> MAP_TYPE_REFERENCE = new TypeReference>() {
+ public static final TypeReference> MAP_TYPE_REFERENCE = new TypeReference<>() {
};
- private final RetryPolicy saveRetryPolicy = new RetryPolicy().retryOn(IOException.class).withDelay(1000, TimeUnit.MILLISECONDS).withMaxRetries(3);
+ private final RetryPolicy saveRetryPolicy = RetryPolicy.builder().withDelay(Duration.ofMillis(1000)).withMaxRetries(3).handle(IOException.class).build();
public void save(BaseConfig baseConfig) {
@@ -81,11 +84,20 @@ public class ConfigReaderWriter {
save(converted, buildConfigFileFile());
}
+ @SuppressWarnings({"Convert2Lambda", "Convert2Diamond"}) // Will not work with diamond
protected void save(File targetFile, String configAsYamlString) {
+ if (NzbHydra.isNativeBuild()) {
+ return;
+ }
synchronized (Jackson.YAML_MAPPER) {
Failsafe.with(saveRetryPolicy)
- .onFailure(throwable -> logger.error("Unable to save config", throwable))
- .run(() -> doWrite(targetFile, configAsYamlString))
+ .onFailure(new EventListener>() {
+ @Override
+ public void accept(ExecutionCompletedEvent