diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..f6ce96d481 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,341 @@ +#------------------- +# DSpace's dependabot rules. Enables maven updates for all dependencies on a weekly basis +# for main and any maintenance branches. Security updates only apply to main. +#------------------- +version: 2 +updates: + ############### + ## Main branch + ############### + # NOTE: At this time, "security-updates" rules only apply if "target-branch" is unspecified + # So, only this first section can include "applies-to: security-updates" + - package-ecosystem: "maven" + directory: "/" + schedule: + interval: "weekly" + # Allow up to 10 open PRs for dependencies + open-pull-requests-limit: 10 + # Group together some upgrades in a single PR + groups: + # Group together all Build Tools in a single PR + build-tools: + applies-to: version-updates + patterns: + - "org.apache.maven.plugins:*" + - "*:*-maven-plugin" + - "*:maven-*-plugin" + - "com.github.spotbugs:spotbugs" + - "com.google.code.findbugs:*" + - "com.google.errorprone:*" + - "com.puppycrawl.tools:checkstyle" + - "org.sonatype.plugins:*" + exclude-patterns: + # Exclude anything from Spring, as that is in a separate group + - "org.springframework.*:*" + update-types: + - "minor" + - "patch" + test-tools: + applies-to: version-updates + patterns: + - "junit:*" + - "com.github.stefanbirker:system-rules" + - "com.h2database:*" + - "io.findify:s3mock*" + - "io.netty:*" + - "org.hamcrest:*" + - "org.mock-server:*" + - "org.mockito:*" + update-types: + - "minor" + - "patch" + # Group together all Apache Commons deps in a single PR + apache-commons: + applies-to: version-updates + patterns: + - "org.apache.commons:*" + - "commons-*:commons-*" + update-types: + - "minor" + - "patch" + # Group together all fasterxml deps in a single PR + fasterxml: + applies-to: version-updates + patterns: + - "com.fasterxml:*" + - "com.fasterxml.*:*" + update-types: + - "minor" + - "patch" + # Group together all Hibernate deps in a single PR + hibernate: + applies-to: version-updates + patterns: + - "org.hibernate.*:*" + update-types: + - "minor" + - "patch" + # Group together all Jakarta deps in a single PR + jakarta: + applies-to: version-updates + patterns: + - "jakarta.*:*" + - "org.eclipse.angus:jakarta.mail" + - "org.glassfish.jaxb:jaxb-runtime" + update-types: + - "minor" + - "patch" + # Group together all Spring deps in a single PR + spring: + applies-to: version-updates + patterns: + - "org.springframework:*" + - "org.springframework.*:*" + update-types: + - "minor" + - "patch" + # Group together all WebJARs deps in a single PR + webjars: + applies-to: version-updates + patterns: + - "org.webjars:*" + - "org.webjars.*:*" + update-types: + - "minor" + - "patch" + ignore: + # Don't try to auto-update any DSpace dependencies + - dependency-name: "org.dspace:*" + - dependency-name: "org.dspace.*:*" + # Ignore all major version updates for all dependencies. We'll only automate minor/patch updates. + - dependency-name: "*" + update-types: ["version-update:semver-major"] + ###################### + ## dspace-8_x branch + ###################### + - package-ecosystem: "maven" + directory: "/" + target-branch: dspace-8_x + schedule: + interval: "weekly" + # Allow up to 10 open PRs for dependencies + open-pull-requests-limit: 10 + # Group together some upgrades in a single PR + groups: + # Group together all Build Tools in a single PR + build-tools: + applies-to: version-updates + patterns: + - "org.apache.maven.plugins:*" + - "*:*-maven-plugin" + - "*:maven-*-plugin" + - "com.github.spotbugs:spotbugs" + - "com.google.code.findbugs:*" + - "com.google.errorprone:*" + - "com.puppycrawl.tools:checkstyle" + - "org.sonatype.plugins:*" + exclude-patterns: + # Exclude anything from Spring, as that is in a separate group + - "org.springframework.*:*" + update-types: + - "minor" + - "patch" + test-tools: + applies-to: version-updates + patterns: + - "junit:*" + - "com.github.stefanbirker:system-rules" + - "com.h2database:*" + - "io.findify:s3mock*" + - "io.netty:*" + - "org.hamcrest:*" + - "org.mock-server:*" + - "org.mockito:*" + update-types: + - "minor" + - "patch" + # Group together all Apache Commons deps in a single PR + apache-commons: + applies-to: version-updates + patterns: + - "org.apache.commons:*" + - "commons-*:commons-*" + update-types: + - "minor" + - "patch" + # Group together all fasterxml deps in a single PR + fasterxml: + applies-to: version-updates + patterns: + - "com.fasterxml:*" + - "com.fasterxml.*:*" + update-types: + - "minor" + - "patch" + # Group together all Hibernate deps in a single PR + hibernate: + applies-to: version-updates + patterns: + - "org.hibernate.*:*" + update-types: + - "minor" + - "patch" + # Group together all Jakarta deps in a single PR + jakarta: + applies-to: version-updates + patterns: + - "jakarta.*:*" + - "org.eclipse.angus:jakarta.mail" + - "org.glassfish.jaxb:jaxb-runtime" + update-types: + - "minor" + - "patch" + # Group together all Spring deps in a single PR + spring: + applies-to: version-updates + patterns: + - "org.springframework:*" + - "org.springframework.*:*" + update-types: + - "minor" + - "patch" + # Group together all WebJARs deps in a single PR + webjars: + applies-to: version-updates + patterns: + - "org.webjars:*" + - "org.webjars.*:*" + update-types: + - "minor" + - "patch" + ignore: + # Don't try to auto-update any DSpace dependencies + - dependency-name: "org.dspace:*" + - dependency-name: "org.dspace.*:*" + # Ignore all major version updates for all dependencies. We'll only automate minor/patch updates. + - dependency-name: "*" + update-types: [ "version-update:semver-major" ] + ###################### + ## dspace-7_x branch + ###################### + - package-ecosystem: "maven" + directory: "/" + target-branch: dspace-7_x + schedule: + interval: "weekly" + # Allow up to 10 open PRs for dependencies + open-pull-requests-limit: 10 + # Group together some upgrades in a single PR + groups: + # Group together all Build Tools in a single PR + build-tools: + applies-to: version-updates + patterns: + - "org.apache.maven.plugins:*" + - "*:*-maven-plugin" + - "*:maven-*-plugin" + - "com.github.spotbugs:spotbugs" + - "com.google.code.findbugs:*" + - "com.google.errorprone:*" + - "com.puppycrawl.tools:checkstyle" + - "org.sonatype.plugins:*" + exclude-patterns: + # Exclude anything from Spring, as that is in a separate group + - "org.springframework.*:*" + update-types: + - "minor" + - "patch" + test-tools: + applies-to: version-updates + patterns: + - "junit:*" + - "com.github.stefanbirker:system-rules" + - "com.h2database:*" + - "io.findify:s3mock*" + - "io.netty:*" + - "org.hamcrest:*" + - "org.mock-server:*" + - "org.mockito:*" + update-types: + - "minor" + - "patch" + # Group together all Apache Commons deps in a single PR + apache-commons: + applies-to: version-updates + patterns: + - "org.apache.commons:*" + - "commons-*:commons-*" + update-types: + - "minor" + - "patch" + # Group together all fasterxml deps in a single PR + fasterxml: + applies-to: version-updates + patterns: + - "com.fasterxml:*" + - "com.fasterxml.*:*" + update-types: + - "minor" + - "patch" + # Group together all Hibernate deps in a single PR + hibernate: + applies-to: version-updates + patterns: + - "org.hibernate.*:*" + update-types: + - "minor" + - "patch" + # Group together all Jakarta deps in a single PR + jakarta: + applies-to: version-updates + patterns: + - "jakarta.*:*" + - "org.eclipse.angus:jakarta.mail" + - "org.glassfish.jaxb:jaxb-runtime" + update-types: + - "minor" + - "patch" + # Group together all Google deps in a single PR + # NOTE: These Google deps are only used in 7.x and have been removed in 8.x and later + google-apis: + applies-to: version-updates + patterns: + - "com.google.apis:*" + - "com.google.api-client:*" + - "com.google.http-client:*" + - "com.google.oauth-client:*" + update-types: + - "minor" + - "patch" + # Group together all Spring deps in a single PR + spring: + applies-to: version-updates + patterns: + - "org.springframework:*" + - "org.springframework.*:*" + update-types: + - "minor" + - "patch" + # Group together all WebJARs deps in a single PR + webjars: + applies-to: version-updates + patterns: + - "org.webjars:*" + - "org.webjars.*:*" + update-types: + - "minor" + - "patch" + ignore: + # Don't try to auto-update any DSpace dependencies + - dependency-name: "org.dspace:*" + - dependency-name: "org.dspace.*:*" + # Last version of errorprone to support JDK 11 is 2.31.0 + - dependency-name: "com.google.errorprone:*" + versions: [">=2.32.0"] + # Spring Security 5.8 changes the behavior of CSRF Tokens in a way which is incompatible with DSpace 7 + # See https://github.com/DSpace/DSpace/pull/9888#issuecomment-2408165545 + - dependency-name: "org.springframework.security:*" + versions: [">=5.8.0"] + # Ignore all major version updates for all dependencies. We'll only automate minor/patch updates. + - dependency-name: "*" + update-types: [ "version-update:semver-major" ] diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a9ff8760e7..9d32cb119d 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -15,6 +15,7 @@ on: permissions: contents: read # to fetch code (actions/checkout) + packages: write # to write images to GitHub Container Registry (GHCR) jobs: #################################################### @@ -147,4 +148,102 @@ jobs: tags_flavor: suffix=-loadsql secrets: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} - DOCKER_ACCESS_TOKEN: ${{ secrets.DOCKER_ACCESS_TOKEN }} \ No newline at end of file + DOCKER_ACCESS_TOKEN: ${{ secrets.DOCKER_ACCESS_TOKEN }} + + ################################################################################# + # Test Deployment via Docker to ensure newly built images are working properly + ################################################################################# + docker-deploy: + # Ensure this job never runs on forked repos. It's only executed for 'dspace/dspace' + if: github.repository == 'dspace/dspace' + runs-on: ubuntu-latest + # Must run after all major images are built + needs: [dspace, dspace-test, dspace-cli, dspace-postgres-pgcrypto, dspace-solr] + env: + # Override defaults dspace.server.url because backend starts at http://127.0.0.1:8080 + dspace__P__server__P__url: http://127.0.0.1:8080/server + # Enable all optional modules / controllers for this test deployment. + # This helps check for errors in deploying these modules via Spring Boot + iiif__P__enabled: true + ldn__P__enabled: true + oai__P__enabled: true + rdf__P__enabled: true + signposting__P__enabled: true + sword__D__server__P__enabled: true + swordv2__D__server__P__enabled: true + # If this is a PR against main (default branch), use "latest". + # Else if this is a PR against a different branch, used the base branch name. + # Else if this is a commit on main (default branch), use the "latest" tag. + # Else, just use the branch name. + # NOTE: DSPACE_VER is used because our docker compose scripts default to using the "-test" image. + DSPACE_VER: ${{ (github.event_name == 'pull_request' && github.event.pull_request.base.ref == github.event.repository.default_branch && 'latest') || (github.event_name == 'pull_request' && github.event.pull_request.base.ref) || (github.ref_name == github.event.repository.default_branch && 'latest') || github.ref_name }} + # Docker Registry to use for Docker compose scripts below. + # We use GitHub's Container Registry to avoid aggressive rate limits at DockerHub. + DOCKER_REGISTRY: ghcr.io + steps: + # Checkout our codebase (to get access to Docker Compose scripts) + - name: Checkout codebase + uses: actions/checkout@v4 + # Download Docker image artifacts (which were just built by reusable-docker-build.yml) + - name: Download Docker image artifacts + uses: actions/download-artifact@v4 + with: + # Download all amd64 Docker images (TAR files) into the /tmp/docker directory + pattern: docker-image-*-linux-amd64 + path: /tmp/docker + merge-multiple: true + # Load each of the images into Docker by calling "docker image load" for each. + # This ensures we are using the images just built & not any prior versions on DockerHub + - name: Load all downloaded Docker images + run: | + find /tmp/docker -type f -name "*.tar" -exec docker image load --input "{}" \; + docker image ls -a + # Start backend using our compose script in the codebase. + - name: Start backend in Docker + run: | + docker compose -f docker-compose.yml up -d + sleep 10 + docker container ls + # Create a test admin account. Load test data from a simple set of AIPs as defined in cli.ingest.yml + - name: Load test data into Backend + run: | + docker compose -f docker-compose-cli.yml run --rm dspace-cli create-administrator -e test@test.edu -f admin -l user -p admin -c en + docker compose -f docker-compose-cli.yml -f dspace/src/main/docker-compose/cli.ingest.yml run --rm dspace-cli + # Verify backend started successfully. + # 1. Make sure root endpoint is responding (check for dspace.name defined in docker-compose.yml) + # 2. Also check /collections endpoint to ensure the test data loaded properly (check for a collection name in AIPs) + - name: Verify backend is responding properly + run: | + result=$(wget -O- -q http://127.0.0.1:8080/server/api) + echo "$result" + echo "$result" | grep -oE "\"DSpace Started with Docker Compose\"," + result=$(wget -O- -q http://127.0.0.1:8080/server/api/core/collections) + echo "$result" + echo "$result" | grep -oE "\"Dog in Yard\"," + # Verify Handle Server can be stared and is working properly + # 1. First generate the "[dspace]/handle-server" folder with the sitebndl.zip + # 2. Start the Handle Server (and wait 20 seconds to let it start up) + # 3. Verify logs do NOT include "Exception" in the text (as that means an error occurred) + # 4. Check that Handle Proxy HTML page is responding on default port (8000) + - name: Verify Handle Server is working properly + run: | + docker exec -i dspace /dspace/bin/make-handle-config + echo "Starting Handle Server..." + docker exec -i dspace /dspace/bin/start-handle-server + sleep 20 + echo "Checking for errors in error.log" + result=$(docker exec -i dspace sh -c "cat /dspace/handle-server/logs/error.log* || echo ''") + echo "$result" + echo "$result" | grep -vqz "Exception" + echo "Checking for errors in handle-server.log..." + result=$(docker exec -i dspace cat /dspace/log/handle-server.log) + echo "$result" + echo "$result" | grep -vqz "Exception" + echo "Checking to see if Handle Proxy webpage is available..." + result=$(wget -O- -q http://127.0.0.1:8000/) + echo "$result" + echo "$result" | grep -oE "Handle Proxy" + # Shutdown our containers + - name: Shutdown Docker containers + run: | + docker compose -f docker-compose.yml down diff --git a/.github/workflows/reusable-docker-build.yml b/.github/workflows/reusable-docker-build.yml index 7a8de661fa..7a8abda3e1 100644 --- a/.github/workflows/reusable-docker-build.yml +++ b/.github/workflows/reusable-docker-build.yml @@ -54,10 +54,13 @@ env: # For a new commit on default branch (main), use the literal tag 'latest' on Docker image. # For a new commit on other branches, use the branch name as the tag for Docker image. # For a new tag, copy that tag name as the tag for Docker image. + # For a pull request, use the name of the base branch that the PR was created against or "latest" (for main). + # e.g. PR against 'main' will use "latest". a PR against 'dspace-7_x' will use 'dspace-7_x'. IMAGE_TAGS: | type=raw,value=latest,enable=${{ github.ref_name == github.event.repository.default_branch }} type=ref,event=branch,enable=${{ github.ref_name != github.event.repository.default_branch }} type=ref,event=tag + type=raw,value=${{ (github.event.pull_request.base.ref == github.event.repository.default_branch && 'latest') || github.event.pull_request.base.ref }},enable=${{ github.event_name == 'pull_request' }} # Define default tag "flavor" for docker/metadata-action per # https://github.com/docker/metadata-action#flavor-input # We manage the 'latest' tag ourselves to the 'main' branch (see settings above) @@ -72,6 +75,9 @@ env: DEPLOY_DEMO_BRANCH: 'dspace-8_x' DEPLOY_SANDBOX_BRANCH: 'main' DEPLOY_ARCH: 'linux/amd64' + # Registry used during building of Docker images. (All images are later copied to docker.io registry) + # We use GitHub's Container Registry to avoid aggressive rate limits at DockerHub. + DOCKER_BUILD_REGISTRY: ghcr.io jobs: docker-build: @@ -96,6 +102,7 @@ jobs: # This step converts the slashes in the "arch" matrix values above into dashes & saves to env.ARCH_NAME # E.g. "linux/amd64" becomes "linux-amd64" # This is necessary because all upload artifacts CANNOT have special chars (like slashes) + # NOTE: The regex-like syntax below is Bash Parameter Substitution - name: Prepare run: | platform=${{ matrix.arch }} @@ -105,35 +112,45 @@ jobs: - name: Checkout codebase uses: actions/checkout@v4 - # https://github.com/docker/setup-buildx-action - - name: Setup Docker Buildx - uses: docker/setup-buildx-action@v3 + # https://github.com/docker/login-action + # NOTE: This login occurs for BOTH non-PRs or PRs. PRs *must* also login to access private images from GHCR + # during the build process + - name: Login to ${{ env.DOCKER_BUILD_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_BUILD_REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} # https://github.com/docker/setup-qemu-action - name: Set up QEMU emulation to build for multiple architectures uses: docker/setup-qemu-action@v3 - # https://github.com/docker/login-action - - name: Login to DockerHub - # Only login if not a PR, as PRs only trigger a Docker build and not a push - if: ${{ ! matrix.isPr }} - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_ACCESS_TOKEN }} + # https://github.com/docker/setup-buildx-action + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 # https://github.com/docker/metadata-action - # Get Metadata for docker_build_deps step below - - name: Sync metadata (tags, labels) from GitHub to Docker for image + # Extract metadata used for Docker images in all build steps below + - name: Extract metadata (tags, labels) from GitHub for Docker image id: meta_build uses: docker/metadata-action@v5 with: - images: ${{ env.IMAGE_NAME }} + images: ${{ env.DOCKER_BUILD_REGISTRY }}/${{ env.IMAGE_NAME }} tags: ${{ env.IMAGE_TAGS }} flavor: ${{ env.TAGS_FLAVOR }} + #-------------------------------------------------------------------- + # First, for all branch commits (non-PRs) we build the image & upload + # to GitHub Container Registry (GHCR). After uploading the image + # to GHCR, we store the image digest in an artifact, so we can + # create a merged manifest later (see 'docker-build_manifest' job). + # + # NOTE: We use GHCR in order to avoid aggressive rate limits at DockerHub. + #-------------------------------------------------------------------- # https://github.com/docker/build-push-action - - name: Build and push image + - name: Build and push image to ${{ env.DOCKER_BUILD_REGISTRY }} + if: ${{ ! matrix.isPr }} id: docker_build uses: docker/build-push-action@v5 with: @@ -141,15 +158,20 @@ jobs: ${{ inputs.dockerfile_additional_contexts }} context: ${{ inputs.dockerfile_context }} file: ${{ inputs.dockerfile_path }} + # Tell DSpace's Docker files to use the build registry instead of DockerHub + build-args: + DOCKER_REGISTRY=${{ env.DOCKER_BUILD_REGISTRY }} platforms: ${{ matrix.arch }} - # For pull requests, we run the Docker build (to ensure no PR changes break the build), - # but we ONLY do an image push to DockerHub if it's NOT a PR - push: ${{ ! matrix.isPr }} + push: true # Use tags / labels provided by 'docker/metadata-action' above tags: ${{ steps.meta_build.outputs.tags }} labels: ${{ steps.meta_build.outputs.labels }} + # Use GitHub cache to load cached Docker images and cache the results of this build + # This decreases the number of images we need to fetch from DockerHub + cache-from: type=gha,scope=${{ inputs.build_id }} + cache-to: type=gha,scope=${{ inputs.build_id }},mode=max - # Export the digest of Docker build locally (for non PRs only) + # Export the digest of Docker build locally - name: Export Docker build digest if: ${{ ! matrix.isPr }} run: | @@ -157,7 +179,8 @@ jobs: digest="${{ steps.docker_build.outputs.digest }}" touch "/tmp/digests/${digest#sha256:}" - # Upload digest to an artifact, so that it can be used in manifest below + # Upload digest to an artifact, so that it can be used in combined manifest below + # (The purpose of the combined manifest is to list both amd64 and arm64 builds under same tag) - name: Upload Docker build digest to artifact if: ${{ ! matrix.isPr }} uses: actions/upload-artifact@v4 @@ -167,33 +190,60 @@ jobs: if-no-files-found: error retention-days: 1 - # If this build is NOT a PR and passed in a REDEPLOY_SANDBOX_URL secret, - # Then redeploy https://sandbox.dspace.org if this build is for our deployment architecture and 'main' branch. - - name: Redeploy sandbox.dspace.org (based on main branch) - if: | - !matrix.isPR && - env.REDEPLOY_SANDBOX_URL != '' && - matrix.arch == env.DEPLOY_ARCH && - github.ref_name == env.DEPLOY_SANDBOX_BRANCH - run: | - curl -X POST $REDEPLOY_SANDBOX_URL + #------------------------------------------------------------------------------ + # Second, we build the image again in order to store it in a local TAR file. + # This TAR of the image is cached/saved as an artifact, so that it can be used + # by later jobs to install the brand-new images for automated testing. + # This TAR build is performed BOTH for PRs and for branch commits (non-PRs). + # + # (This approach has the advantage of avoiding having to download the newly built + # image from DockerHub or GHCR during automated testing.) + # + # See the 'docker-deploy' job in docker.yml as an example of where this TAR is used. + #------------------------------------------------------------------------------- + # Build local image (again) and store in a TAR file in /tmp directory + # This step is only done for AMD64, as that's the only image we use in our automated testing (at this time). + # NOTE: This step cannot be combined with the build above as it's a different type of output. + - name: Build and push image to local TAR file + if: ${{ matrix.arch == 'linux/amd64'}} + uses: docker/build-push-action@v5 + with: + build-contexts: | + ${{ inputs.dockerfile_additional_contexts }} + context: ${{ inputs.dockerfile_context }} + file: ${{ inputs.dockerfile_path }} + # Tell DSpace's Docker files to use the build registry instead of DockerHub + build-args: + DOCKER_REGISTRY=${{ env.DOCKER_BUILD_REGISTRY }} + platforms: ${{ matrix.arch }} + tags: ${{ steps.meta_build.outputs.tags }} + labels: ${{ steps.meta_build.outputs.labels }} + # Use GitHub cache to load cached Docker images and cache the results of this build + # This decreases the number of images we need to fetch from DockerHub + cache-from: type=gha,scope=${{ inputs.build_id }} + cache-to: type=gha,scope=${{ inputs.build_id }},mode=max + # Export image to a local TAR file + outputs: type=docker,dest=/tmp/${{ inputs.build_id }}.tar - # If this build is NOT a PR and passed in a REDEPLOY_DEMO_URL secret, - # Then redeploy https://demo.dspace.org if this build is for our deployment architecture and demo branch. - - name: Redeploy demo.dspace.org (based on maintenance branch) - if: | - !matrix.isPR && - env.REDEPLOY_DEMO_URL != '' && - matrix.arch == env.DEPLOY_ARCH && - github.ref_name == env.DEPLOY_DEMO_BRANCH - run: | - curl -X POST $REDEPLOY_DEMO_URL + # Upload the local docker image (in TAR file) to a build Artifact + # This step is only done for AMD64, as that's the only image we use in our automated testing (at this time). + - name: Upload local image TAR to artifact + if: ${{ matrix.arch == 'linux/amd64'}} + uses: actions/upload-artifact@v4 + with: + name: docker-image-${{ inputs.build_id }}-${{ env.ARCH_NAME }} + path: /tmp/${{ inputs.build_id }}.tar + if-no-files-found: error + retention-days: 1 - # Merge Docker digests (from various architectures) into a manifest. - # This runs after all Docker builds complete above, and it tells hub.docker.com - # that these builds should be all included in the manifest for this tag. - # (e.g. AMD64 and ARM64 should be listed as options under the same tagged Docker image) + ########################################################################################## + # Merge Docker digests (from various architectures) into a single manifest. + # This runs after all Docker builds complete above. The purpose is to include all builds + # under a single manifest for this tag. + # (e.g. both linux/amd64 and linux/arm64 should be listed under the same tagged Docker image) + ########################################################################################## docker-build_manifest: + # Only run if this is NOT a PR if: ${{ github.event_name != 'pull_request' }} runs-on: ubuntu-latest needs: @@ -207,29 +257,102 @@ jobs: pattern: digests-${{ inputs.build_id }}-* merge-multiple: true + - name: Login to ${{ env.DOCKER_BUILD_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_BUILD_REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Add Docker metadata for image id: meta uses: docker/metadata-action@v5 + with: + images: ${{ env.DOCKER_BUILD_REGISTRY }}/${{ env.IMAGE_NAME }} + tags: ${{ env.IMAGE_TAGS }} + flavor: ${{ env.TAGS_FLAVOR }} + + - name: Create manifest list from digests and push to ${{ env.DOCKER_BUILD_REGISTRY }} + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.DOCKER_BUILD_REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) + + - name: Inspect manifest in ${{ env.DOCKER_BUILD_REGISTRY }} + run: | + docker buildx imagetools inspect ${{ env.DOCKER_BUILD_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} + + ########################################################################################## + # Copy images / manifest to DockerHub. + # This MUST run after *both* images (AMD64 and ARM64) are built and uploaded to GitHub + # Container Registry (GHCR). Attempting to run this in parallel to GHCR builds can result + # in a race condition...i.e. the copy to DockerHub may fail if GHCR image has been updated + # at the moment when the copy occurs. + ########################################################################################## + docker-copy_to_dockerhub: + # Only run if this is NOT a PR + if: ${{ github.event_name != 'pull_request' }} + runs-on: ubuntu-latest + needs: + - docker-build_manifest + + steps: + # 'regctl' is used to more easily copy the image to DockerHub and obtain the digest from DockerHub + # See https://github.com/regclient/regclient/blob/main/docs/regctl.md + - name: Install regctl for Docker registry tools + uses: regclient/actions/regctl-installer@main + with: + release: 'v0.8.0' + + # This recreates Docker tags for DockerHub + - name: Add Docker metadata for image + id: meta_dockerhub + uses: docker/metadata-action@v5 with: images: ${{ env.IMAGE_NAME }} tags: ${{ env.IMAGE_TAGS }} flavor: ${{ env.TAGS_FLAVOR }} - - name: Login to Docker Hub + # Login to source registry first, as this is where we are copying *from* + - name: Login to ${{ env.DOCKER_BUILD_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_BUILD_REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Login to DockerHub, since this is where we are copying *to* + - name: Login to DockerHub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_ACCESS_TOKEN }} - - name: Create manifest list from digests and push - working-directory: /tmp/digests + # Copy the image from source to DockerHub + - name: Copy image from ${{ env.DOCKER_BUILD_REGISTRY }} to docker.io run: | - docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ - $(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *) + regctl image copy ${{ env.DOCKER_BUILD_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta_dockerhub.outputs.version }} docker.io/${{ env.IMAGE_NAME }}:${{ steps.meta_dockerhub.outputs.version }} - - name: Inspect image + #-------------------------------------------------------------------- + # Finally, check whether demo.dspace.org or sandbox.dspace.org need + # to be redeployed based on these new DockerHub images. + #-------------------------------------------------------------------- + # If this build is for the branch that Sandbox uses and passed in a REDEPLOY_SANDBOX_URL secret, + # Then redeploy https://sandbox.dspace.org + - name: Redeploy sandbox.dspace.org (based on main branch) + if: | + env.REDEPLOY_SANDBOX_URL != '' && + github.ref_name == env.DEPLOY_SANDBOX_BRANCH run: | - docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} + curl -X POST $REDEPLOY_SANDBOX_URL + # If this build is for the branch that Demo uses and passed in a REDEPLOY_DEMO_URL secret, + # Then redeploy https://demo.dspace.org + - name: Redeploy demo.dspace.org (based on maintenance branch) + if: | + env.REDEPLOY_DEMO_URL != '' && + github.ref_name == env.DEPLOY_DEMO_BRANCH + run: | + curl -X POST $REDEPLOY_DEMO_URL \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2fcb46b993..a181638660 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ tags .project .classpath .checkstyle +.factorypath ## Ignore project files created by IntelliJ IDEA *.iml @@ -27,6 +28,9 @@ nbdist/ nbactions.xml nb-configuration.xml +## Ignore project files created by Visual Studio Code +.vscode/ + ## Ignore all *.properties file in root folder, EXCEPT build.properties (the default) ## KEPT FOR BACKWARDS COMPATIBILITY WITH 5.x (build.properties is now replaced with local.cfg) /*.properties diff --git a/Dockerfile b/Dockerfile index 7581798037..5aece8b7d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,10 +6,14 @@ # This Dockerfile uses JDK17 by default. # To build with other versions, use "--build-arg JDK_VERSION=[value]" ARG JDK_VERSION=17 +# The Docker version tag to build from ARG DSPACE_VERSION=latest +# The Docker registry to use for DSpace images. Defaults to "docker.io" +# NOTE: non-DSpace images are hardcoded to use "docker.io" and are not impacted by this build argument +ARG DOCKER_REGISTRY=docker.io # Step 1 - Run Maven Build -FROM dspace/dspace-dependencies:${DSPACE_VERSION} AS build +FROM ${DOCKER_REGISTRY}/dspace/dspace-dependencies:${DSPACE_VERSION} AS build ARG TARGET_DIR=dspace-installer WORKDIR /app # The dspace-installer directory will be written to /install @@ -31,35 +35,38 @@ RUN mvn --no-transfer-progress package ${MAVEN_FLAGS} && \ RUN rm -rf /install/webapps/server/ # Step 2 - Run Ant Deploy -FROM eclipse-temurin:${JDK_VERSION} AS ant_build +FROM docker.io/eclipse-temurin:${JDK_VERSION} AS ant_build ARG TARGET_DIR=dspace-installer # COPY the /install directory from 'build' container to /dspace-src in this container COPY --from=build /install /dspace-src WORKDIR /dspace-src # Create the initial install deployment using ANT -ENV ANT_VERSION 1.10.13 -ENV ANT_HOME /tmp/ant-$ANT_VERSION -ENV PATH $ANT_HOME/bin:$PATH -# Need wget to install ant -RUN apt-get update \ - && apt-get install -y --no-install-recommends wget \ - && apt-get purge -y --auto-remove \ - && rm -rf /var/lib/apt/lists/* +ENV ANT_VERSION=1.10.13 +ENV ANT_HOME=/tmp/ant-$ANT_VERSION +ENV PATH=$ANT_HOME/bin:$PATH # Download and install 'ant' RUN mkdir $ANT_HOME && \ - wget -qO- "https://archive.apache.org/dist/ant/binaries/apache-ant-$ANT_VERSION-bin.tar.gz" | tar -zx --strip-components=1 -C $ANT_HOME + curl --silent --show-error --location --fail --retry 5 --output /tmp/apache-ant.tar.gz \ + https://archive.apache.org/dist/ant/binaries/apache-ant-${ANT_VERSION}-bin.tar.gz && \ + tar -zx --strip-components=1 -f /tmp/apache-ant.tar.gz -C $ANT_HOME && \ + rm /tmp/apache-ant.tar.gz # Run necessary 'ant' deploy scripts RUN ant init_installation update_configs update_code update_webapps # Step 3 - Start up DSpace via Runnable JAR -FROM eclipse-temurin:${JDK_VERSION} +FROM docker.io/eclipse-temurin:${JDK_VERSION} # NOTE: DSPACE_INSTALL must align with the "dspace.dir" default configuration. ENV DSPACE_INSTALL=/dspace # Copy the /dspace directory from 'ant_build' container to /dspace in this container COPY --from=ant_build /dspace $DSPACE_INSTALL WORKDIR $DSPACE_INSTALL -# Expose Tomcat port -EXPOSE 8080 +# Need host command for "[dspace]/bin/make-handle-config" +RUN apt-get update \ + && apt-get install -y --no-install-recommends host \ + && apt-get purge -y --auto-remove \ + && rm -rf /var/lib/apt/lists/* +# Expose Tomcat port (8080) & Handle Server HTTP port (8000) +EXPOSE 8080 8000 # Give java extra memory (2GB) ENV JAVA_OPTS=-Xmx2000m # On startup, run DSpace Runnable JAR diff --git a/Dockerfile.cli b/Dockerfile.cli index 5254d1eb4d..e43c8eb95d 100644 --- a/Dockerfile.cli +++ b/Dockerfile.cli @@ -6,10 +6,14 @@ # This Dockerfile uses JDK17 by default. # To build with other versions, use "--build-arg JDK_VERSION=[value]" ARG JDK_VERSION=17 +# The Docker version tag to build from ARG DSPACE_VERSION=latest +# The Docker registry to use for DSpace images. Defaults to "docker.io" +# NOTE: non-DSpace images are hardcoded to use "docker.io" and are not impacted by this build argument +ARG DOCKER_REGISTRY=docker.io # Step 1 - Run Maven Build -FROM dspace/dspace-dependencies:${DSPACE_VERSION} AS build +FROM ${DOCKER_REGISTRY}/dspace/dspace-dependencies:${DSPACE_VERSION} AS build ARG TARGET_DIR=dspace-installer WORKDIR /app # The dspace-installer directory will be written to /install @@ -25,28 +29,26 @@ RUN mvn --no-transfer-progress package && \ mvn clean # Step 2 - Run Ant Deploy -FROM eclipse-temurin:${JDK_VERSION} AS ant_build +FROM docker.io/eclipse-temurin:${JDK_VERSION} AS ant_build ARG TARGET_DIR=dspace-installer # COPY the /install directory from 'build' container to /dspace-src in this container COPY --from=build /install /dspace-src WORKDIR /dspace-src # Create the initial install deployment using ANT -ENV ANT_VERSION 1.10.13 -ENV ANT_HOME /tmp/ant-$ANT_VERSION -ENV PATH $ANT_HOME/bin:$PATH -# Need wget to install ant -RUN apt-get update \ - && apt-get install -y --no-install-recommends wget \ - && apt-get purge -y --auto-remove \ - && rm -rf /var/lib/apt/lists/* +ENV ANT_VERSION=1.10.13 +ENV ANT_HOME=/tmp/ant-$ANT_VERSION +ENV PATH=$ANT_HOME/bin:$PATH # Download and install 'ant' RUN mkdir $ANT_HOME && \ - wget -qO- "https://archive.apache.org/dist/ant/binaries/apache-ant-$ANT_VERSION-bin.tar.gz" | tar -zx --strip-components=1 -C $ANT_HOME + curl --silent --show-error --location --fail --retry 5 --output /tmp/apache-ant.tar.gz \ + https://archive.apache.org/dist/ant/binaries/apache-ant-${ANT_VERSION}-bin.tar.gz && \ + tar -zx --strip-components=1 -f /tmp/apache-ant.tar.gz -C $ANT_HOME && \ + rm /tmp/apache-ant.tar.gz # Run necessary 'ant' deploy scripts RUN ant init_installation update_configs update_code # Step 3 - Run jdk -FROM eclipse-temurin:${JDK_VERSION} +FROM docker.io/eclipse-temurin:${JDK_VERSION} # NOTE: DSPACE_INSTALL must align with the "dspace.dir" default configuration. ENV DSPACE_INSTALL=/dspace # Copy the /dspace directory from 'ant_build' container to /dspace in this container diff --git a/Dockerfile.dependencies b/Dockerfile.dependencies index f3bf1f8332..84a6b41aee 100644 --- a/Dockerfile.dependencies +++ b/Dockerfile.dependencies @@ -6,8 +6,8 @@ # To build with other versions, use "--build-arg JDK_VERSION=[value]" ARG JDK_VERSION=17 -# Step 1 - Run Maven Build -FROM maven:3-eclipse-temurin-${JDK_VERSION} AS build +# Step 1 - Download all Dependencies +FROM docker.io/maven:3-eclipse-temurin-${JDK_VERSION} AS build ARG TARGET_DIR=dspace-installer WORKDIR /app # Create the 'dspace' user account & home directory @@ -19,16 +19,64 @@ RUN chown -Rv dspace: /app # Switch to dspace user & run below commands as that user USER dspace -# Copy the DSpace source code (from local machine) into the workdir (excluding .dockerignore contents) -ADD --chown=dspace . /app/ +# This next part may look odd, but it speeds up the build of this image *significantly*. +# Copy ONLY the POMs to this image (from local machine). This will allow us to download all dependencies *without* +# performing any code compilation steps. + +# Parent POM +ADD --chown=dspace pom.xml /app/ +RUN mkdir -p /app/dspace + +# 'dspace' module POM. Includes 'additions' ONLY, as it's the only submodule that is required to exist. +ADD --chown=dspace dspace/pom.xml /app/dspace/ +RUN mkdir -p /app/dspace/modules/ +ADD --chown=dspace dspace/modules/pom.xml /app/dspace/modules/ +RUN mkdir -p /app/dspace/modules/additions +ADD --chown=dspace dspace/modules/additions/pom.xml /app/dspace/modules/additions/ + +# 'dspace-api' module POM +RUN mkdir -p /app/dspace-api +ADD --chown=dspace dspace-api/pom.xml /app/dspace-api/ + +# 'dspace-iiif' module POM +RUN mkdir -p /app/dspace-iiif +ADD --chown=dspace dspace-iiif/pom.xml /app/dspace-iiif/ + +# 'dspace-oai' module POM +RUN mkdir -p /app/dspace-oai +ADD --chown=dspace dspace-oai/pom.xml /app/dspace-oai/ + +# 'dspace-rdf' module POM +RUN mkdir -p /app/dspace-rdf +ADD --chown=dspace dspace-rdf/pom.xml /app/dspace-rdf/ + +# 'dspace-saml2' module POM +RUN mkdir -p /app/dspace-saml2 +ADD --chown=dspace dspace-saml2/pom.xml /app/dspace-saml2/ + +# 'dspace-server-webapp' module POM +RUN mkdir -p /app/dspace-server-webapp +ADD --chown=dspace dspace-server-webapp/pom.xml /app/dspace-server-webapp/ + +# 'dspace-services' module POM +RUN mkdir -p /app/dspace-services +ADD --chown=dspace dspace-services/pom.xml /app/dspace-services/ + +# 'dspace-sword' module POM +RUN mkdir -p /app/dspace-sword +ADD --chown=dspace dspace-sword/pom.xml /app/dspace-sword/ + +# 'dspace-swordv2' module POM +RUN mkdir -p /app/dspace-swordv2 +ADD --chown=dspace dspace-swordv2/pom.xml /app/dspace-swordv2/ # Trigger the installation of all maven dependencies (hide download progress messages) # Maven flags here ensure that we skip final assembly, skip building test environment and skip all code verification checks. -# These flags speed up this installation as much as reasonably possible. -ENV MAVEN_FLAGS="-P-assembly -P-test-environment -Denforcer.skip=true -Dcheckstyle.skip=true -Dlicense.skip=true -Dxml.skip=true" -RUN mvn --no-transfer-progress install ${MAVEN_FLAGS} +# These flags speed up this installation and skip tasks we cannot perform as we don't have the full source code. +ENV MAVEN_FLAGS="-P-assembly -P-test-environment -Denforcer.skip=true -Dcheckstyle.skip=true -Dlicense.skip=true -Dxjc.skip=true -Dxml.skip=true" +RUN mvn --no-transfer-progress verify ${MAVEN_FLAGS} -# Clear the contents of the /app directory (including all maven builds), so no artifacts remain. +# Clear the contents of the /app directory (including all maven target folders), so no artifacts remain. # This ensures when dspace:dspace is built, it will use the Maven local cache (~/.m2) for dependencies USER root RUN rm -rf /app/* diff --git a/Dockerfile.test b/Dockerfile.test index f3acef00e8..8f48328a14 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -8,10 +8,14 @@ # This Dockerfile uses JDK17 by default. # To build with other versions, use "--build-arg JDK_VERSION=[value]" ARG JDK_VERSION=17 +# The Docker version tag to build from ARG DSPACE_VERSION=latest +# The Docker registry to use for DSpace images. Defaults to "docker.io" +# NOTE: non-DSpace images are hardcoded to use "docker.io" and are not impacted by this build argument +ARG DOCKER_REGISTRY=docker.io # Step 1 - Run Maven Build -FROM dspace/dspace-dependencies:${DSPACE_VERSION} AS build +FROM ${DOCKER_REGISTRY}/dspace/dspace-dependencies:${DSPACE_VERSION} AS build ARG TARGET_DIR=dspace-installer WORKDIR /app # The dspace-installer directory will be written to /install @@ -30,38 +34,41 @@ RUN mvn --no-transfer-progress package && \ RUN rm -rf /install/webapps/server/ # Step 2 - Run Ant Deploy -FROM eclipse-temurin:${JDK_VERSION} AS ant_build +FROM docker.io/eclipse-temurin:${JDK_VERSION} AS ant_build ARG TARGET_DIR=dspace-installer # COPY the /install directory from 'build' container to /dspace-src in this container COPY --from=build /install /dspace-src WORKDIR /dspace-src # Create the initial install deployment using ANT -ENV ANT_VERSION 1.10.12 -ENV ANT_HOME /tmp/ant-$ANT_VERSION -ENV PATH $ANT_HOME/bin:$PATH -# Need wget to install ant -RUN apt-get update \ - && apt-get install -y --no-install-recommends wget \ - && apt-get purge -y --auto-remove \ - && rm -rf /var/lib/apt/lists/* +ENV ANT_VERSION=1.10.12 +ENV ANT_HOME=/tmp/ant-$ANT_VERSION +ENV PATH=$ANT_HOME/bin:$PATH # Download and install 'ant' RUN mkdir $ANT_HOME && \ - wget -qO- "https://archive.apache.org/dist/ant/binaries/apache-ant-$ANT_VERSION-bin.tar.gz" | tar -zx --strip-components=1 -C $ANT_HOME + curl --silent --show-error --location --fail --retry 5 --output /tmp/apache-ant.tar.gz \ + https://archive.apache.org/dist/ant/binaries/apache-ant-${ANT_VERSION}-bin.tar.gz && \ + tar -zx --strip-components=1 -f /tmp/apache-ant.tar.gz -C $ANT_HOME && \ + rm /tmp/apache-ant.tar.gz # Run necessary 'ant' deploy scripts RUN ant init_installation update_configs update_code update_webapps # Step 3 - Start up DSpace via Runnable JAR -FROM eclipse-temurin:${JDK_VERSION} +FROM docker.io/eclipse-temurin:${JDK_VERSION} # NOTE: DSPACE_INSTALL must align with the "dspace.dir" default configuration. ENV DSPACE_INSTALL=/dspace # Copy the /dspace directory from 'ant_build' container to /dspace in this container COPY --from=ant_build /dspace $DSPACE_INSTALL WORKDIR $DSPACE_INSTALL +# Need host command for "[dspace]/bin/make-handle-config" +RUN apt-get update \ + && apt-get install -y --no-install-recommends host \ + && apt-get purge -y --auto-remove \ + && rm -rf /var/lib/apt/lists/* # Expose Tomcat port and debugging port EXPOSE 8080 8000 # Give java extra memory (2GB) ENV JAVA_OPTS=-Xmx2000m -# Set up debugging -ENV CATALINA_OPTS=-Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:8000 +# enable JVM debugging via JDWP +ENV JAVA_TOOL_OPTIONS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8000 # On startup, run DSpace Runnable JAR ENTRYPOINT ["java", "-jar", "webapps/server-boot.jar", "--dspace.dir=$DSPACE_INSTALL"] diff --git a/checkstyle.xml b/checkstyle.xml index e0fa808d83..a33fc48319 100644 --- a/checkstyle.xml +++ b/checkstyle.xml @@ -92,7 +92,7 @@ For more information on CheckStyle configurations below, see: http://checkstyle. - + diff --git a/docker-compose-cli.yml b/docker-compose-cli.yml index 91f89916d2..3e2c9ba6a5 100644 --- a/docker-compose-cli.yml +++ b/docker-compose-cli.yml @@ -6,7 +6,7 @@ networks: external: true services: dspace-cli: - image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-latest}" + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-latest}" container_name: dspace-cli build: context: . diff --git a/docker-compose.yml b/docker-compose.yml index 6a930a8d31..ab4f8adc98 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,7 @@ services: # from the host machine. This IP range MUST correspond to the 'dspacenet' subnet defined above. proxies__P__trusted__P__ipranges: '172.23.0' LOGGING_CONFIG: /dspace/config/log4j2-container.xml - image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}" + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}" build: context: . dockerfile: Dockerfile.test @@ -64,7 +64,7 @@ services: dspacedb: container_name: dspacedb # Uses a custom Postgres image with pgcrypto installed - image: "${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}" + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}" build: # Must build out of subdirectory to have access to install script for pgcrypto context: ./dspace/src/main/docker/dspace-postgres-pgcrypto/ @@ -84,7 +84,7 @@ services: # DSpace Solr container dspacesolr: container_name: dspacesolr - image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}" + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}" build: context: ./dspace/src/main/docker/dspace-solr/ # Provide path to Solr configs necessary to build Docker image diff --git a/dspace-api/pom.xml b/dspace-api/pom.xml index ea182a7483..e34ca90c1e 100644 --- a/dspace-api/pom.xml +++ b/dspace-api/pom.xml @@ -102,7 +102,7 @@ org.codehaus.mojo build-helper-maven-plugin - 3.4.0 + 3.6.0 validate @@ -116,7 +116,7 @@ org.codehaus.mojo buildnumber-maven-plugin - 3.2.0 + 3.2.1 UNKNOWN_REVISION @@ -177,7 +177,7 @@ org.codehaus.mojo jaxb2-maven-plugin - 3.1.0 + 3.2.0 workflow-curation @@ -341,6 +341,14 @@ org.apache.logging.log4j log4j-api + + org.apache.logging.log4j + log4j-core + + + org.apache.logging.log4j + log4j-slf4j2-impl + org.hibernate.orm hibernate-core @@ -388,6 +396,13 @@ org.springframework spring-orm + + + + org.springframework + spring-jcl + + @@ -406,6 +421,16 @@ org.mortbay.jasper apache-jsp + + + org.bouncycastle + bcpkix-jdk15on + + + org.bouncycastle + bcprov-jdk15on + @@ -623,7 +648,7 @@ dnsjava dnsjava - 3.6.0 + 3.6.3 @@ -667,28 +692,6 @@ ${flyway.version} - - - com.google.apis - google-api-services-analytics - - - com.google.api-client - google-api-client - - - com.google.http-client - google-http-client - - - com.google.http-client - google-http-client-jackson2 - - - com.google.oauth-client - google-oauth-client - - com.google.code.findbugs @@ -702,7 +705,6 @@ jakarta.inject jakarta.inject-api - 2.0.1 @@ -733,7 +735,7 @@ com.amazonaws aws-java-sdk-s3 - 1.12.261 + 1.12.781 + + org.yaml + snakeyaml + @@ -769,25 +776,27 @@ com.opencsv opencsv - 5.9 + 5.10 org.apache.velocity velocity-engine-core + 2.4.1 org.xmlunit xmlunit-core + 2.10.0 test org.apache.bcel bcel - 6.7.0 + 6.10.0 test @@ -814,7 +823,7 @@ org.mock-server mockserver-junit-rule - 5.11.2 + 5.15.0 test @@ -856,75 +865,4 @@ - - - - - - - io.netty - netty-buffer - 4.1.106.Final - - - io.netty - netty-transport - 4.1.106.Final - - - io.netty - netty-transport-native-unix-common - 4.1.106.Final - - - io.netty - netty-common - 4.1.106.Final - - - io.netty - netty-handler - 4.1.106.Final - - - io.netty - netty-codec - 4.1.106.Final - - - org.apache.velocity - velocity-engine-core - 2.3 - - - org.xmlunit - xmlunit-core - 2.10.0 - test - - - com.github.java-json-tools - json-schema-validator - 2.2.14 - - - jakarta.validation - jakarta.validation-api - 3.0.2 - - - io.swagger - swagger-core - 1.6.2 - - - org.scala-lang - scala-library - 2.13.11 - test - - - - diff --git a/dspace-api/src/main/java/org/dspace/access/status/DefaultAccessStatusHelper.java b/dspace-api/src/main/java/org/dspace/access/status/DefaultAccessStatusHelper.java index 5f0e6d8b25..52cdec3517 100644 --- a/dspace-api/src/main/java/org/dspace/access/status/DefaultAccessStatusHelper.java +++ b/dspace-api/src/main/java/org/dspace/access/status/DefaultAccessStatusHelper.java @@ -8,6 +8,7 @@ package org.dspace.access.status; import java.sql.SQLException; +import java.time.Instant; import java.util.Date; import java.util.List; import java.util.Objects; @@ -26,7 +27,6 @@ import org.dspace.content.service.ItemService; import org.dspace.core.Constants; import org.dspace.core.Context; import org.dspace.eperson.Group; -import org.joda.time.LocalDate; /** * Default plugin implementation of the access status helper. @@ -230,7 +230,7 @@ public class DefaultAccessStatusHelper implements AccessStatusHelper { // If the policy is not valid there is an active embargo Date startDate = policy.getStartDate(); - if (startDate != null && !startDate.before(LocalDate.now().toDate())) { + if (startDate != null && !startDate.before(Date.from(Instant.now()))) { // There is an active embargo: aim to take the shortest embargo (account for rare cases where // more than one resource policy exists) if (embargoDate == null) { diff --git a/dspace-api/src/main/java/org/dspace/app/mediafilter/TikaTextExtractionFilter.java b/dspace-api/src/main/java/org/dspace/app/mediafilter/TikaTextExtractionFilter.java index e83bf706ed..17e7b85e9b 100644 --- a/dspace-api/src/main/java/org/dspace/app/mediafilter/TikaTextExtractionFilter.java +++ b/dspace-api/src/main/java/org/dspace/app/mediafilter/TikaTextExtractionFilter.java @@ -18,6 +18,7 @@ import java.nio.charset.StandardCharsets; import org.apache.commons.lang.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.apache.poi.util.IOUtils; import org.apache.tika.Tika; import org.apache.tika.exception.TikaException; import org.apache.tika.metadata.Metadata; @@ -72,21 +73,23 @@ public class TikaTextExtractionFilter // Not using temporary file. We'll use Tika's default in-memory parsing. // Get maximum characters to extract. Default is 100,000 chars, which is also Tika's default setting. String extractedText; - int maxChars = configurationService.getIntProperty("textextractor.max-chars", 100000); + int maxChars = configurationService.getIntProperty("textextractor.max-chars", 100_000); try { // Use Tika to extract text from input. Tika will automatically detect the file type. Tika tika = new Tika(); tika.setMaxStringLength(maxChars); // Tell Tika the maximum number of characters to extract + IOUtils.setByteArrayMaxOverride( + configurationService.getIntProperty("textextractor.max-array", 100_000_000)); extractedText = tika.parseToString(source); } catch (IOException e) { System.err.format("Unable to extract text from bitstream in Item %s%n", currentItem.getID().toString()); - e.printStackTrace(); + e.printStackTrace(System.err); log.error("Unable to extract text from bitstream in Item {}", currentItem.getID().toString(), e); throw e; } catch (OutOfMemoryError oe) { System.err.format("OutOfMemoryError occurred when extracting text from bitstream in Item %s. " + "You may wish to enable 'textextractor.use-temp-file'.%n", currentItem.getID().toString()); - oe.printStackTrace(); + oe.printStackTrace(System.err); log.error("OutOfMemoryError occurred when extracting text from bitstream in Item {}. " + "You may wish to enable 'textextractor.use-temp-file'.", currentItem.getID().toString(), oe); throw oe; diff --git a/dspace-api/src/main/java/org/dspace/app/statistics/LogAnalyser.java b/dspace-api/src/main/java/org/dspace/app/statistics/LogAnalyser.java index 982c339963..2408a693b6 100644 --- a/dspace-api/src/main/java/org/dspace/app/statistics/LogAnalyser.java +++ b/dspace-api/src/main/java/org/dspace/app/statistics/LogAnalyser.java @@ -281,10 +281,14 @@ public class LogAnalyser { */ private static String fileTemplate = "dspace\\.log.*"; + private static final ConfigurationService configurationService = + DSpaceServicesFactory.getInstance().getConfigurationService(); + /** * the configuration file from which to configure the analyser */ - private static String configFile; + private static String configFile = configurationService.getProperty("dspace.dir") + + File.separator + "config" + File.separator + "dstat.cfg"; /** * the output file to which to write aggregation data @@ -616,8 +620,6 @@ public class LogAnalyser { } // now do the host name and url lookup - ConfigurationService configurationService - = DSpaceServicesFactory.getInstance().getConfigurationService(); hostName = Utils.getHostName(configurationService.getProperty("dspace.ui.url")); name = configurationService.getProperty("dspace.name").trim(); url = configurationService.getProperty("dspace.ui.url").trim(); @@ -658,8 +660,6 @@ public class LogAnalyser { String myConfigFile, String myOutFile, Date myStartDate, Date myEndDate, boolean myLookUp) { - ConfigurationService configurationService - = DSpaceServicesFactory.getInstance().getConfigurationService(); if (myLogDir != null) { logDir = myLogDir; @@ -673,9 +673,6 @@ public class LogAnalyser { if (myConfigFile != null) { configFile = myConfigFile; - } else { - configFile = configurationService.getProperty("dspace.dir") - + File.separator + "config" + File.separator + "dstat.cfg"; } if (myStartDate != null) { diff --git a/dspace-api/src/main/java/org/dspace/app/statistics/package.html b/dspace-api/src/main/java/org/dspace/app/statistics/package.html index a6d8d8699c..931a703908 100644 --- a/dspace-api/src/main/java/org/dspace/app/statistics/package.html +++ b/dspace-api/src/main/java/org/dspace/app/statistics/package.html @@ -46,8 +46,6 @@ Several "stock" implementations are provided.
writes event records to the Java logger.
{@link org.dspace.statistics.SolrLoggerUsageEventListener SolrLoggerUsageEventListener}
writes event records to Solr.
-
{@link org.dspace.google.GoogleRecorderEventListener GoogleRecorderEventListener}<.dt> -
writes event records to Google Analytics.
diff --git a/dspace-api/src/main/java/org/dspace/app/util/AuthorizeUtil.java b/dspace-api/src/main/java/org/dspace/app/util/AuthorizeUtil.java index ed59f4b24f..9b63750e43 100644 --- a/dspace-api/src/main/java/org/dspace/app/util/AuthorizeUtil.java +++ b/dspace-api/src/main/java/org/dspace/app/util/AuthorizeUtil.java @@ -523,9 +523,9 @@ public class AuthorizeUtil { for (Collection coll : colls) { if (!AuthorizeConfiguration - .canCollectionAdminPerformItemReinstatiate()) { + .canCollectionAdminPerformItemReinstate()) { if (AuthorizeConfiguration - .canCommunityAdminPerformItemReinstatiate() + .canCommunityAdminPerformItemReinstate() && authorizeService.authorizeActionBoolean(context, coll.getCommunities().get(0), Constants.ADMIN)) { // authorized diff --git a/dspace-api/src/main/java/org/dspace/app/util/DCInput.java b/dspace-api/src/main/java/org/dspace/app/util/DCInput.java index dd88390cb8..c96be33d01 100644 --- a/dspace-api/src/main/java/org/dspace/app/util/DCInput.java +++ b/dspace-api/src/main/java/org/dspace/app/util/DCInput.java @@ -163,7 +163,7 @@ public class DCInput { * The scope of the input sets, this restricts hidden metadata fields from * view by the end user during submission. */ - public static final String SUBMISSION_SCOPE = "submit"; + public static final String SUBMISSION_SCOPE = "submission"; /** * Class constructor for creating a DCInput object based on the contents of @@ -262,7 +262,7 @@ public class DCInput { /** * Is this DCInput for display in the given scope? The scope should be - * either "workflow" or "submit", as per the input forms definition. If the + * either "workflow" or "submission", as per the input forms definition. If the * internal visibility is set to "null" then this will always return true. * * @param scope String identifying the scope that this input's visibility diff --git a/dspace-api/src/main/java/org/dspace/app/util/OpenSearchServiceImpl.java b/dspace-api/src/main/java/org/dspace/app/util/OpenSearchServiceImpl.java index bff741b5ca..2800ae6d10 100644 --- a/dspace-api/src/main/java/org/dspace/app/util/OpenSearchServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/app/util/OpenSearchServiceImpl.java @@ -14,7 +14,6 @@ import java.sql.SQLException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.Map; import com.rometools.modules.opensearch.OpenSearchModule; import com.rometools.modules.opensearch.entity.OSQuery; @@ -58,12 +57,12 @@ public class OpenSearchServiceImpl implements OpenSearchService { private static final Logger log = org.apache.logging.log4j.LogManager.getLogger(OpenSearchServiceImpl.class); // Namespaces used - protected final String osNs = "http://a9.com/-/spec/opensearch/1.1/"; + protected final static String osNs = "http://a9.com/-/spec/opensearch/1.1/"; - @Autowired(required = true) + @Autowired protected ConfigurationService configurationService; - @Autowired(required = true) + @Autowired protected HandleService handleService; protected OpenSearchServiceImpl() { @@ -119,11 +118,10 @@ public class OpenSearchServiceImpl implements OpenSearchService { @Override public String getResultsString(Context context, String format, String query, int totalResults, int start, - int pageSize, - IndexableObject scope, List results, - Map labels) throws IOException { + int pageSize, IndexableObject scope, List results) + throws IOException { try { - return getResults(context, format, query, totalResults, start, pageSize, scope, results, labels) + return getResults(context, format, query, totalResults, start, pageSize, scope, results) .outputString(); } catch (FeedException e) { log.error(e.toString(), e); @@ -133,11 +131,10 @@ public class OpenSearchServiceImpl implements OpenSearchService { @Override public Document getResultsDoc(Context context, String format, String query, int totalResults, int start, - int pageSize, - IndexableObject scope, List results, Map labels) + int pageSize, IndexableObject scope, List results) throws IOException { try { - return getResults(context, format, query, totalResults, start, pageSize, scope, results, labels) + return getResults(context, format, query, totalResults, start, pageSize, scope, results) .outputW3CDom(); } catch (FeedException e) { log.error(e.toString(), e); @@ -146,8 +143,7 @@ public class OpenSearchServiceImpl implements OpenSearchService { } protected SyndicationFeed getResults(Context context, String format, String query, int totalResults, int start, - int pageSize, IndexableObject scope, - List results, Map labels) { + int pageSize, IndexableObject scope, List results) { // Encode results in requested format if ("rss".equals(format)) { format = "rss_2.0"; @@ -156,7 +152,7 @@ public class OpenSearchServiceImpl implements OpenSearchService { } SyndicationFeed feed = new SyndicationFeed(); - feed.populate(null, context, scope, results, labels); + feed.populate(null, context, scope, results); feed.setType(format); feed.addModule(openSearchMarkup(query, totalResults, start, pageSize)); return feed; diff --git a/dspace-api/src/main/java/org/dspace/app/util/SyndicationFeed.java b/dspace-api/src/main/java/org/dspace/app/util/SyndicationFeed.java index b4ba69ffa2..5d64009727 100644 --- a/dspace-api/src/main/java/org/dspace/app/util/SyndicationFeed.java +++ b/dspace-api/src/main/java/org/dspace/app/util/SyndicationFeed.java @@ -11,6 +11,7 @@ import java.io.IOException; import java.sql.SQLException; import java.util.ArrayList; import java.util.Date; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -135,8 +136,6 @@ public class SyndicationFeed { protected String[] podcastableMIMETypes = configurationService.getArrayProperty("webui.feed.podcast.mimetypes", new String[] {"audio/x-mpeg"}); - // -------- Instance variables: - // the feed object we are building protected SyndFeed feed = null; @@ -146,9 +145,6 @@ public class SyndicationFeed { protected CommunityService communityService; protected ItemService itemService; - /** - * Constructor. - */ public SyndicationFeed() { feed = new SyndFeedImpl(); ContentServiceFactory contentServiceFactory = ContentServiceFactory.getInstance(); @@ -157,16 +153,6 @@ public class SyndicationFeed { communityService = contentServiceFactory.getCommunityService(); } - /** - * Returns list of metadata selectors used to compose the description element - * - * @return selector list - format 'schema.element[.qualifier]' - */ - public static String[] getDescriptionSelectors() { - return (String[]) ArrayUtils.clone(descriptionFields); - } - - /** * Fills in the feed and entry-level metadata from DSpace objects. * @@ -174,15 +160,17 @@ public class SyndicationFeed { * @param context context * @param dso the scope * @param items array of objects - * @param labels label map */ public void populate(HttpServletRequest request, Context context, IndexableObject dso, - List items, Map labels) { + List items) { String logoURL = null; String objectURL = null; String defaultTitle = null; boolean podcastFeed = false; this.request = request; + + Map labels = getLabels(); + // dso is null for the whole site, or a search without scope if (dso == null) { defaultTitle = configurationService.getProperty("dspace.name"); @@ -553,5 +541,19 @@ public class SyndicationFeed { List dcv = itemService.getMetadataByMetadataString(item, field); return (dcv.size() > 0) ? dcv.get(0).getValue() : null; } -} + /** + * Internal method to get labels for the returned document + */ + private Map getLabels() { + // TODO: get strings from translation file or configuration + Map labelMap = new HashMap<>(); + labelMap.put(SyndicationFeed.MSG_UNTITLED, "notitle"); + labelMap.put(SyndicationFeed.MSG_LOGO_TITLE, "logo.title"); + labelMap.put(SyndicationFeed.MSG_FEED_DESCRIPTION, "general-feed.description"); + for (String selector : descriptionFields) { + labelMap.put("metadata." + selector, selector); + } + return labelMap; + } +} diff --git a/dspace-api/src/main/java/org/dspace/app/util/service/OpenSearchService.java b/dspace-api/src/main/java/org/dspace/app/util/service/OpenSearchService.java index cf62bca30e..78b208faa2 100644 --- a/dspace-api/src/main/java/org/dspace/app/util/service/OpenSearchService.java +++ b/dspace-api/src/main/java/org/dspace/app/util/service/OpenSearchService.java @@ -10,7 +10,6 @@ package org.dspace.app.util.service; import java.io.IOException; import java.sql.SQLException; import java.util.List; -import java.util.Map; import org.dspace.content.DSpaceObject; import org.dspace.core.Context; @@ -86,14 +85,12 @@ public interface OpenSearchService { * @param pageSize - page size * @param scope - search scope, null or the community/collection * @param results the retrieved DSpace objects satisfying search - * @param labels labels to apply - format specific * @return formatted search results * @throws IOException if IO error */ public String getResultsString(Context context, String format, String query, int totalResults, int start, - int pageSize, - IndexableObject scope, List results, - Map labels) throws IOException; + int pageSize, IndexableObject scope, List results) + throws IOException; /** * Returns a formatted set of search results as a document @@ -106,13 +103,11 @@ public interface OpenSearchService { * @param pageSize - page size * @param scope - search scope, null or the community/collection * @param results the retrieved DSpace objects satisfying search - * @param labels labels to apply - format specific * @return formatted search results * @throws IOException if IO error */ public Document getResultsDoc(Context context, String format, String query, int totalResults, int start, - int pageSize, - IndexableObject scope, List results, Map labels) + int pageSize, IndexableObject scope, List results) throws IOException; public DSpaceObject resolveScope(Context context, String scope) throws SQLException; diff --git a/dspace-api/src/main/java/org/dspace/authenticate/LDAPAuthentication.java b/dspace-api/src/main/java/org/dspace/authenticate/LDAPAuthentication.java index b791df15b5..40b8f48078 100644 --- a/dspace-api/src/main/java/org/dspace/authenticate/LDAPAuthentication.java +++ b/dspace-api/src/main/java/org/dspace/authenticate/LDAPAuthentication.java @@ -17,6 +17,7 @@ import java.util.Collections; import java.util.Hashtable; import java.util.Iterator; import java.util.List; +import java.util.Optional; import javax.naming.NamingEnumeration; import javax.naming.NamingException; import javax.naming.directory.Attribute; @@ -68,12 +69,8 @@ import org.dspace.services.factory.DSpaceServicesFactory; * @author Ivan Masár * @author Michael Plate */ -public class LDAPAuthentication - implements AuthenticationMethod { +public class LDAPAuthentication implements AuthenticationMethod { - /** - * log4j category - */ private static final Logger log = org.apache.logging.log4j.LogManager.getLogger(LDAPAuthentication.class); @@ -130,7 +127,7 @@ public class LDAPAuthentication return false; } - /* + /** * This is an explicit method. */ @Override @@ -138,7 +135,7 @@ public class LDAPAuthentication return false; } - /* + /** * Add authenticated users to the group defined in dspace.cfg by * the login.specialgroup key. */ @@ -177,7 +174,7 @@ public class LDAPAuthentication return Collections.EMPTY_LIST; } - /* + /** * Authenticate the given credentials. * This is the heart of the authentication method: test the * credentials for authenticity, and if accepted, attempt to match @@ -187,7 +184,7 @@ public class LDAPAuthentication * @param context * DSpace context, will be modified (ePerson set) upon success. * - * @param username + * @param netid * Username (or email address) when method is explicit. Use null for * implicit method. * @@ -250,7 +247,7 @@ public class LDAPAuthentication } // Check a DN was found - if ((dn == null) || (dn.trim().equals(""))) { + if (StringUtils.isBlank(dn)) { log.info(LogHelper .getHeader(context, "failed_login", "no DN found for user " + netid)); return BAD_CREDENTIALS; @@ -269,6 +266,18 @@ public class LDAPAuthentication context.setCurrentUser(eperson); request.setAttribute(LDAP_AUTHENTICATED, true); + // update eperson's attributes + context.turnOffAuthorisationSystem(); + setEpersonAttributes(context, eperson, ldap, Optional.empty()); + try { + ePersonService.update(context, eperson); + context.dispatchEvents(); + } catch (AuthorizeException e) { + log.warn("update of eperson " + eperson.getID() + " failed", e); + } finally { + context.restoreAuthSystemState(); + } + // assign user to groups based on ldap dn assignGroups(dn, ldap.ldapGroup, context); @@ -313,14 +322,13 @@ public class LDAPAuthentication log.info(LogHelper.getHeader(context, "type=ldap-login", "type=ldap_but_already_email")); context.turnOffAuthorisationSystem(); - eperson.setNetid(netid.toLowerCase()); + setEpersonAttributes(context, eperson, ldap, Optional.of(netid)); ePersonService.update(context, eperson); context.dispatchEvents(); context.restoreAuthSystemState(); context.setCurrentUser(eperson); request.setAttribute(LDAP_AUTHENTICATED, true); - // assign user to groups based on ldap dn assignGroups(dn, ldap.ldapGroup, context); @@ -331,20 +339,7 @@ public class LDAPAuthentication try { context.turnOffAuthorisationSystem(); eperson = ePersonService.create(context); - if (StringUtils.isNotEmpty(email)) { - eperson.setEmail(email); - } - if (StringUtils.isNotEmpty(ldap.ldapGivenName)) { - eperson.setFirstName(context, ldap.ldapGivenName); - } - if (StringUtils.isNotEmpty(ldap.ldapSurname)) { - eperson.setLastName(context, ldap.ldapSurname); - } - if (StringUtils.isNotEmpty(ldap.ldapPhone)) { - ePersonService.setMetadataSingleValue(context, eperson, - MD_PHONE, ldap.ldapPhone, null); - } - eperson.setNetid(netid.toLowerCase()); + setEpersonAttributes(context, eperson, ldap, Optional.of(netid)); eperson.setCanLogIn(true); authenticationService.initEPerson(context, request, eperson); ePersonService.update(context, eperson); @@ -382,6 +377,29 @@ public class LDAPAuthentication return BAD_ARGS; } + /** + * Update eperson's attributes + */ + private void setEpersonAttributes(Context context, EPerson eperson, SpeakerToLDAP ldap, Optional netid) + throws SQLException { + + if (StringUtils.isNotEmpty(ldap.ldapEmail)) { + eperson.setEmail(ldap.ldapEmail); + } + if (StringUtils.isNotEmpty(ldap.ldapGivenName)) { + eperson.setFirstName(context, ldap.ldapGivenName); + } + if (StringUtils.isNotEmpty(ldap.ldapSurname)) { + eperson.setLastName(context, ldap.ldapSurname); + } + if (StringUtils.isNotEmpty(ldap.ldapPhone)) { + ePersonService.setMetadataSingleValue(context, eperson, MD_PHONE, ldap.ldapPhone, null); + } + if (netid.isPresent()) { + eperson.setNetid(netid.get().toLowerCase()); + } + } + /** * Internal class to manage LDAP query and results, mainly * because there are multiple values to return. @@ -503,6 +521,7 @@ public class LDAPAuthentication } else { searchName = ldap_provider_url + ldap_search_context; } + @SuppressWarnings("BanJNDI") NamingEnumeration answer = ctx.search( searchName, "(&({0}={1}))", new Object[] {ldap_id_field, @@ -553,7 +572,7 @@ public class LDAPAuthentication att = atts.get(attlist[4]); if (att != null) { // loop through all groups returned by LDAP - ldapGroup = new ArrayList(); + ldapGroup = new ArrayList<>(); for (NamingEnumeration val = att.getAll(); val.hasMoreElements(); ) { ldapGroup.add((String) val.next()); } @@ -633,7 +652,8 @@ public class LDAPAuthentication ctx.addToEnvironment(javax.naming.Context.AUTHORITATIVE, "true"); ctx.addToEnvironment(javax.naming.Context.REFERRAL, "follow"); // dummy operation to check if authentication has succeeded - ctx.getAttributes(""); + @SuppressWarnings("BanJNDI") + Attributes trash = ctx.getAttributes(""); } else if (!useTLS) { // Authenticate env.put(javax.naming.Context.SECURITY_AUTHENTICATION, "Simple"); @@ -671,7 +691,7 @@ public class LDAPAuthentication } } - /* + /** * Returns the URL of an external login page which is not applicable for this authn method. * * Note: Prior to DSpace 7, this method return the page of login servlet. @@ -699,7 +719,7 @@ public class LDAPAuthentication return "ldap"; } - /* + /** * Add authenticated users to the group defined in dspace.cfg by * the authentication-ldap.login.groupmap.* key. * diff --git a/dspace-api/src/main/java/org/dspace/authenticate/SamlAuthentication.java b/dspace-api/src/main/java/org/dspace/authenticate/SamlAuthentication.java new file mode 100644 index 0000000000..385be8b8fc --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/authenticate/SamlAuthentication.java @@ -0,0 +1,711 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.authenticate; + +import java.sql.SQLException; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.dspace.authenticate.factory.AuthenticateServiceFactory; +import org.dspace.authorize.AuthorizeException; +import org.dspace.content.MetadataField; +import org.dspace.content.MetadataSchema; +import org.dspace.content.MetadataSchemaEnum; +import org.dspace.content.NonUniqueMetadataException; +import org.dspace.content.factory.ContentServiceFactory; +import org.dspace.content.service.MetadataFieldService; +import org.dspace.content.service.MetadataSchemaService; +import org.dspace.core.Context; +import org.dspace.eperson.EPerson; +import org.dspace.eperson.Group; +import org.dspace.eperson.factory.EPersonServiceFactory; +import org.dspace.eperson.service.EPersonService; +import org.dspace.eperson.service.GroupService; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; + +/** + * SAML authentication for DSpace. + * + * @author Ray Lee + */ +public class SamlAuthentication implements AuthenticationMethod { + private static final Logger log = LogManager.getLogger(SamlAuthentication.class); + + // Additional metadata mappings. + protected Map metadataHeaderMap = null; + + protected EPersonService ePersonService = EPersonServiceFactory.getInstance().getEPersonService(); + protected GroupService groupService = EPersonServiceFactory.getInstance().getGroupService(); + protected MetadataFieldService metadataFieldService = ContentServiceFactory.getInstance().getMetadataFieldService(); + + protected MetadataSchemaService metadataSchemaService = + ContentServiceFactory.getInstance().getMetadataSchemaService(); + + protected ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + + /** + * Authenticate the given or implicit credentials. This is the heart of the + * authentication method: test the credentials for authenticity, and if + * accepted, attempt to match (or optionally, create) an + * EPerson. If an EPerson is found it is set in + * the Context that was passed. + * + * DSpace supports authentication using NetID or email address. A user's NetID + * is a unique identifier from the IdP that identifies a particular user. The + * NetID can be of almost any form, such as a unique integer or string. In + * SAML, this is referred to as a Name ID. + * + * There are two ways to supply identity information to DSpace: + * + * 1) Name ID from SAML attribute (best) + * + * The Name ID-based method is superior because users may change their email + * address with the identity provider. When this happens DSpace will not be + * able to associate their new address with their old account. + * + * 2) Email address from SAML attribute (okay) + * + * In the case where a Name ID header is not available or not found DSpace + * will fall back to identifying a user based upon their email address. + * + * Identity Scheme Migration Strategies: + * + * If you are currently using Email based authentication (either 1 or 2) and + * want to upgrade to NetID based authentication then there is an easy path. + * Coordinate with the IdP to provide a Name ID in the SAML assertion. When a + * user attempts to log in, DSpace will first look for an EPerson with the + * passed Name ID. When this fails, DSpace will fall back to email based + * authentication. Then DSpace will update the user's EPerson account record + * to set their NetID, so all future authentications for this user will be based + * upon NetID. + * + * DSpace will prevent an account from switching NetIDs. If an account already + * has a NetID set, and a user tries to authenticate with the same email but + * a different NetID, the authentication will fail. + * + * @param context DSpace context, will be modified (EPerson set) upon success. + * @param username Not used by SAML-based authentication. + * @param password Not used by SAML-based authentication. + * @param realm Not used by SAML-based authentication. + * @param request The HTTP request that started this operation. + * @return one of: SUCCESS, NO_SUCH_USER, BAD_ARGS + * @throws SQLException if a database error occurs. + */ + @Override + public int authenticate(Context context, String username, String password, + String realm, HttpServletRequest request) throws SQLException { + + if (request == null) { + log.warn("Unable to authenticate using SAML because the request object is null."); + + return BAD_ARGS; + } + + // Initialize additional EPerson metadata mappings. + + initialize(context); + + String nameId = findSingleAttribute(request, getNameIdAttributeName()); + + if (log.isDebugEnabled()) { + log.debug("Starting SAML Authentication"); + log.debug("Received name ID: " + nameId); + } + + // Should we auto register new users? + + boolean autoRegister = configurationService.getBooleanProperty("authentication-saml.autoregister", true); + + // Four steps to authenticate a user: + + try { + // Step 1: Identify user + + EPerson eperson = findEPerson(context, request); + + // Step 2: Register new user, if necessary + + if (eperson == null && autoRegister) { + eperson = registerNewEPerson(context, request); + } + + if (eperson == null) { + return AuthenticationMethod.NO_SUCH_USER; + } + + if (!eperson.canLogIn()) { + return AuthenticationMethod.BAD_ARGS; + } + + // Step 3: Update user's metadata + + updateEPerson(context, request, eperson); + + // Step 4: Log the user in + + context.setCurrentUser(eperson); + + request.setAttribute("saml.authenticated", true); + + AuthenticateServiceFactory.getInstance().getAuthenticationService().initEPerson(context, request, eperson); + + log.info(eperson.getEmail() + " has been authenticated via SAML."); + + return AuthenticationMethod.SUCCESS; + } catch (Throwable t) { + // Log the error, and undo the authentication before returning a failure. + + log.error("Unable to successfully authenticate using SAML for user because of an exception.", t); + + context.setCurrentUser(null); + + return AuthenticationMethod.NO_SUCH_USER; + } + } + + @Override + public List getSpecialGroups(Context context, HttpServletRequest request) throws SQLException { + return List.of(); + } + + @Override + public boolean allowSetPassword(Context context, HttpServletRequest request, String email) throws SQLException { + // SAML authentication doesn't use a password. + + return false; + } + + @Override + public boolean isImplicit() { + return false; + } + + @Override + public boolean canSelfRegister(Context context, HttpServletRequest request, + String username) throws SQLException { + + // SAML will auto create accounts if configured to do so, but that is not + // the same as self register. Self register means that the user can sign up for + // an account from the web. This is not supported with SAML. + + return false; + } + + @Override + public void initEPerson(Context context, HttpServletRequest request, + EPerson eperson) throws SQLException { + // We don't do anything because all our work is done in authenticate. + } + + /** + * Returns the URL in the SAML relying party service that initiates a login with the IdP, + * as configured. + * + * @see AuthenticationMethod#loginPageURL(Context, HttpServletRequest, HttpServletResponse) + */ + @Override + public String loginPageURL(Context context, HttpServletRequest request, HttpServletResponse response) { + String samlLoginUrl = configurationService.getProperty("authentication-saml.authenticate-endpoint"); + + return response.encodeRedirectURL(samlLoginUrl); + } + + @Override + public String getName() { + return "saml"; + } + + /** + * Check if the SAML plugin is enabled. + * + * @return true if enabled, false otherwise + */ + public static boolean isEnabled() { + final String samlPluginName = new SamlAuthentication().getName(); + boolean samlEnabled = false; + + // Loop through all enabled authentication plugins to see if SAML is one of them. + + Iterator authenticationMethodIterator = + AuthenticateServiceFactory.getInstance().getAuthenticationService().authenticationMethodIterator(); + + while (authenticationMethodIterator.hasNext()) { + if (samlPluginName.equals(authenticationMethodIterator.next().getName())) { + samlEnabled = true; + break; + } + } + return samlEnabled; + } + + /** + * Identify an existing EPerson based upon the SAML attributes provided on + * the request object. + * + * 1) Name ID from SAML attribute (best) + * The Name ID-based method is superior because users may change their email + * address with the identity provider. When this happens DSpace will not be + * able to associate their new address with their old account. + * + * 2) Email address from SAML attribute (okay) + * In the case where a Name ID header is not available or not found DSpace + * will fall back to identifying a user based upon their email address. + * + * If successful then the identified EPerson will be returned, otherwise null. + * + * @param context The DSpace database context + * @param request The current HTTP Request + * @return The EPerson identified or null. + * @throws SQLException if database error + * @throws AuthorizeException if authorization error + */ + protected EPerson findEPerson(Context context, HttpServletRequest request) throws SQLException, AuthorizeException { + String nameId = findSingleAttribute(request, getNameIdAttributeName()); + + if (nameId != null) { + EPerson ePerson = ePersonService.findByNetid(context, nameId); + + if (ePerson == null) { + log.info("Unable to identify EPerson by netid (SAML name ID): " + nameId); + } else { + log.info("Identified EPerson by netid (SAML name ID): " + nameId); + + return ePerson; + } + } + + String emailAttributeName = getEmailAttributeName(); + String email = findSingleAttribute(request, emailAttributeName); + + if (email != null) { + email = email.toLowerCase(); + + EPerson ePerson = ePersonService.findByEmail(context, email); + + if (ePerson == null) { + log.info("Unable to identify EPerson by email: " + emailAttributeName + "=" + email); + } else { + log.info("Identified EPerson by email: " + emailAttributeName + "=" + email); + + if (ePerson.getNetid() == null) { + return ePerson; + } + + // The user has a netid that differs from the received SAML name ID. + + log.error("SAML authentication identified EPerson by email: " + emailAttributeName + "=" + email); + log.error("Received SAML name ID: " + nameId); + log.error("EPerson has netid: " + ePerson.getNetid()); + log.error( + "The SAML name ID is expected to be the same as the EPerson netid. " + + "This might be a hacking attempt to steal another user's credentials. If the " + + "user's netid has changed you will need to manually change it to the correct " + + "value or unset it in the database."); + } + } + + if (nameId == null && email == null) { + log.error( + "SAML authentication did not find a name ID or email in the request from which to indentify a user"); + } + + return null; + } + + + /** + * Register a new EPerson. This method is called when no existing user was + * found for the NetID or email and autoregister is enabled. When these conditions + * are met this method will create a new EPerson object. + * + * In order to create a new EPerson object there is a minimal set of metadata + * required: email, first name, and last name. If we don't have access to these + * three pieces of information then we will be unable to create a new EPerson. + * + * Note that this method only adds the minimal metadata. Any additional metadata + * will need to be added by the updateEPerson method. + * + * @param context The current DSpace database context + * @param request The current HTTP Request + * @return A new EPerson object or null if unable to create a new EPerson. + * @throws SQLException if database error + * @throws AuthorizeException if authorization error + */ + protected EPerson registerNewEPerson(Context context, HttpServletRequest request) + throws SQLException, AuthorizeException { + + String nameId = findSingleAttribute(request, getNameIdAttributeName()); + + String emailAttributeName = getEmailAttributeName(); + String firstNameAttributeName = getFirstNameAttributeName(); + String lastNameAttributeName = getLastNameAttributeName(); + + String email = findSingleAttribute(request, emailAttributeName); + String firstName = findSingleAttribute(request, firstNameAttributeName); + String lastName = findSingleAttribute(request, lastNameAttributeName); + + if (email == null || firstName == null || lastName == null) { + // We require that there be an email, first name, and last name. + + String message = "Unable to register new eperson because we are unable to find an email address, " + + "first name, and last name for the user.\n"; + + message += " name ID: " + nameId + "\n"; + message += " email: " + emailAttributeName + "=" + email + "\n"; + message += " first name: " + firstNameAttributeName + "=" + firstName + "\n"; + message += " last name: " + lastNameAttributeName + "=" + lastName; + + log.error(message); + + return null; + } + + try { + context.turnOffAuthorisationSystem(); + + EPerson ePerson = ePersonService.create(context); + + // Set the minimum attributes for the new eperson + + if (nameId != null) { + ePerson.setNetid(nameId); + } + + ePerson.setEmail(email.toLowerCase()); + ePerson.setFirstName(context, firstName); + ePerson.setLastName(context, lastName); + ePerson.setCanLogIn(true); + ePerson.setSelfRegistered(true); + + // Commit the new eperson + + AuthenticateServiceFactory.getInstance().getAuthenticationService().initEPerson(context, request, ePerson); + + ePersonService.update(context, ePerson); + context.dispatchEvents(); + + if (log.isInfoEnabled()) { + String message = "Auto registered new eperson using SAML attributes:\n"; + + message += " netid: " + ePerson.getNetid() + "\n"; + message += " email: " + ePerson.getEmail() + "\n"; + message += " firstName: " + ePerson.getFirstName() + "\n"; + message += " lastName: " + ePerson.getLastName(); + + log.info(message); + } + + return ePerson; + } catch (SQLException | AuthorizeException e) { + log.error(e.getMessage(), e); + + throw e; + } finally { + context.restoreAuthSystemState(); + } + } + + /** + * After we successfully authenticated a user, this method will update the user's attributes. The + * user's email, name, or other attribute may have been changed since the last time they + * logged into DSpace. This method will update the database with their most recent information. + * + * This method handles the basic DSpace metadata (email, first name, last name) along with + * additional metadata set using the setMetadata() methods on the EPerson object. The + * additional metadata mappings are defined in configuration. + * + * @param context The current DSpace database context + * @param request The current HTTP Request + * @param eperson The eperson object to update. + * @throws SQLException if database error + * @throws AuthorizeException if authorization error + */ + protected void updateEPerson(Context context, HttpServletRequest request, EPerson eperson) + throws SQLException, AuthorizeException { + + String nameId = findSingleAttribute(request, getNameIdAttributeName()); + + String emailAttributeName = getEmailAttributeName(); + String firstNameAttributeName = getFirstNameAttributeName(); + String lastNameAttributeName = getLastNameAttributeName(); + + String email = findSingleAttribute(request, emailAttributeName); + String firstName = findSingleAttribute(request, firstNameAttributeName); + String lastName = findSingleAttribute(request, lastNameAttributeName); + + try { + context.turnOffAuthorisationSystem(); + + // 1) Update the minimum metadata + + // Only update the netid if none has been previously set. This can occur when a repo switches + // to netid based authentication. The current users do not have netids and fall back to email-based + // identification but once they login we update their record and lock the account to a particular netid. + + if (nameId != null && eperson.getNetid() == null) { + eperson.setNetid(nameId); + } + + // The email could have changed if using netid based lookup. + + if (email != null) { + eperson.setEmail(email.toLowerCase()); + } + + if (firstName != null) { + eperson.setFirstName(context, firstName); + } + + if (lastName != null) { + eperson.setLastName(context, lastName); + } + + if (log.isDebugEnabled()) { + String message = "Updated the eperson's minimal metadata: \n"; + + message += " Email: " + emailAttributeName + "=" + email + "' \n"; + message += " First name: " + firstNameAttributeName + "=" + firstName + "\n"; + message += " Last name: " + lastNameAttributeName + "=" + lastName; + + log.debug(message); + } + + // 2) Update additional eperson metadata + + for (String attributeName : metadataHeaderMap.keySet()) { + String metadataFieldName = metadataHeaderMap.get(attributeName); + String value = findSingleAttribute(request, attributeName); + + // Truncate values + + if (value == null) { + log.warn("Unable to update the eperson's '{}' metadata" + + " because the attribute '{}' does not exist.", metadataFieldName, attributeName); + continue; + } + + ePersonService.setMetadataSingleValue(context, eperson, + MetadataSchemaEnum.EPERSON.getName(), metadataFieldName, null, null, value); + + log.debug("Updated the eperson's {} metadata using attribute: {}={}", + metadataFieldName, attributeName, value); + } + + ePersonService.update(context, eperson); + + context.dispatchEvents(); + } catch (SQLException | AuthorizeException e) { + log.error(e.getMessage(), e); + + throw e; + } finally { + context.restoreAuthSystemState(); + } + } + + /** + * Initialize SAML Authentication. + * + * During initalization the mapping of additional EPerson metadata will be loaded from the configuration + * and cached. While loading the metadata mapping this method will check the EPerson object to see + * if it supports the metadata field. If the field is not supported and autocreate is turned on then + * the field will be automatically created. + * + * It is safe to call this method multiple times. + * + * @param context context + * @throws SQLException if database error + */ + protected synchronized void initialize(Context context) throws SQLException { + if (metadataHeaderMap != null) { + return; + } + + HashMap map = new HashMap<>(); + + String[] mappingString = configurationService.getArrayProperty("authentication-saml.eperson.metadata"); + + boolean autoCreate = configurationService + .getBooleanProperty("authentication-saml.eperson.metadata.autocreate", false); + + // Bail out if not set, returning an empty map. + + if (mappingString == null || mappingString.length == 0) { + log.debug("No additional eperson metadata mapping found: authentication-saml.eperson.metadata"); + + metadataHeaderMap = map; + return; + } + + log.debug("Loading additional eperson metadata from: authentication-saml.eperson.metadata=" + + StringUtils.join(mappingString, ",")); + + for (String metadataString : mappingString) { + metadataString = metadataString.trim(); + + String[] metadataParts = metadataString.split("=>"); + + if (metadataParts.length != 2) { + log.error("Unable to parse metadata mapping string: '" + metadataString + "'"); + + continue; + } + + String attributeName = metadataParts[0].trim(); + String metadataFieldName = metadataParts[1].trim().toLowerCase(); + + boolean valid = checkIfEPersonMetadataFieldExists(context, metadataFieldName); + + if (!valid && autoCreate) { + valid = autoCreateEPersonMetadataField(context, metadataFieldName); + } + + if (valid) { + // The eperson field is fine, we can use it. + + log.debug("Loading additional eperson metadata mapping for: {}={}", + attributeName, metadataFieldName); + + map.put(attributeName, metadataFieldName); + } else { + // The field doesn't exist, and we can't use it. + + log.error("Skipping the additional eperson metadata mapping for: {}={}" + + " because the field is not supported by the current configuration.", + attributeName, metadataFieldName); + } + } + + metadataHeaderMap = map; + } + + /** + * Check if a metadata field for an EPerson is available. + * + * @param metadataName The name of the metadata field. + * @param context context + * @return True if a valid metadata field, otherwise false. + * @throws SQLException if database error + */ + protected synchronized boolean checkIfEPersonMetadataFieldExists(Context context, String metadataName) + throws SQLException { + + if (metadataName == null) { + return false; + } + + MetadataField metadataField = metadataFieldService.findByElement( + context, MetadataSchemaEnum.EPERSON.getName(), metadataName, null); + + return metadataField != null; + } + + /** + * Validate metadata field names + */ + protected final String FIELD_NAME_REGEX = "^[_A-Za-z0-9]+$"; + + /** + * Automatically create a new metadata field for an EPerson + * + * @param context context + * @param metadataName The name of the new metadata field. + * @return True if successful, otherwise false. + * @throws SQLException if database error + */ + protected synchronized boolean autoCreateEPersonMetadataField(Context context, String metadataName) + throws SQLException { + + if (metadataName == null) { + return false; + } + + if (!metadataName.matches(FIELD_NAME_REGEX)) { + return false; + } + + MetadataSchema epersonSchema = metadataSchemaService.find(context, "eperson"); + MetadataField metadataField = null; + + try { + context.turnOffAuthorisationSystem(); + + metadataField = metadataFieldService.create(context, epersonSchema, metadataName, null, null); + } catch (AuthorizeException | NonUniqueMetadataException e) { + log.error(e.getMessage(), e); + + return false; + } finally { + context.restoreAuthSystemState(); + } + + return metadataField != null; + } + + @Override + public boolean isUsed(final Context context, final HttpServletRequest request) { + if (request != null && + context.getCurrentUser() != null && + request.getAttribute("saml.authenticated") != null + ) { + return true; + } + + return false; + } + + @Override + public boolean canChangePassword(Context context, EPerson ePerson, String currentPassword) { + return false; + } + + private String findSingleAttribute(HttpServletRequest request, String name) { + if (StringUtils.isBlank(name)) { + return null; + } + + Object value = request.getAttribute(name); + + if (value instanceof List) { + List list = (List) value; + + if (list.size() == 0) { + value = null; + } else { + value = list.get(0); + } + } + + return (value == null ? null : value.toString()); + } + + private String getNameIdAttributeName() { + return configurationService.getProperty("authentication-saml.attribute.name-id", "org.dspace.saml.NAME_ID"); + } + + private String getEmailAttributeName() { + return configurationService.getProperty("authentication-saml.attribute.email", "org.dspace.saml.EMAIL"); + } + + private String getFirstNameAttributeName() { + return configurationService.getProperty("authentication-saml.attribute.first-name", + "org.dspace.saml.GIVEN_NAME"); + } + + private String getLastNameAttributeName() { + return configurationService.getProperty("authentication-saml.attribute.last-name", "org.dspace.saml.SURNAME"); + } +} diff --git a/dspace-api/src/main/java/org/dspace/authorize/AuthorizeConfiguration.java b/dspace-api/src/main/java/org/dspace/authorize/AuthorizeConfiguration.java index 1e051c78b9..65fe46fdd1 100644 --- a/dspace-api/src/main/java/org/dspace/authorize/AuthorizeConfiguration.java +++ b/dspace-api/src/main/java/org/dspace/authorize/AuthorizeConfiguration.java @@ -174,9 +174,9 @@ public class AuthorizeConfiguration { * * @return true/false */ - public static boolean canCommunityAdminPerformItemReinstatiate() { + public static boolean canCommunityAdminPerformItemReinstate() { init(); - return configurationService.getBooleanProperty("core.authorization.community-admin.item.reinstatiate", true); + return configurationService.getBooleanProperty("core.authorization.community-admin.item.reinstate", true); } /** @@ -306,9 +306,9 @@ public class AuthorizeConfiguration { * * @return true/false */ - public static boolean canCollectionAdminPerformItemReinstatiate() { + public static boolean canCollectionAdminPerformItemReinstate() { init(); - return configurationService.getBooleanProperty("core.authorization.collection-admin.item.reinstatiate", true); + return configurationService.getBooleanProperty("core.authorization.collection-admin.item.reinstate", true); } /** diff --git a/dspace-api/src/main/java/org/dspace/browse/BrowseEngine.java b/dspace-api/src/main/java/org/dspace/browse/BrowseEngine.java index 351c362482..be7a34086a 100644 --- a/dspace-api/src/main/java/org/dspace/browse/BrowseEngine.java +++ b/dspace-api/src/main/java/org/dspace/browse/BrowseEngine.java @@ -422,9 +422,6 @@ public class BrowseEngine { } } - // this is the total number of results in answer to the query - int total = getTotalResults(true); - // set the ordering field (there is only one option) dao.setOrderField("sort_value"); @@ -444,6 +441,9 @@ public class BrowseEngine { dao.setOffset(offset); dao.setLimit(scope.getResultsPerPage()); + // this is the total number of results in answer to the query + int total = getTotalResults(true); + // Holder for the results List results = null; @@ -680,33 +680,9 @@ public class BrowseEngine { // tell the browse query whether we are distinct dao.setDistinct(distinct); - // ensure that the select is set to "*" - String[] select = {"*"}; - dao.setCountValues(select); - - // FIXME: it would be nice to have a good way of doing this in the DAO - // now reset all of the fields that we don't want to have constraining - // our count, storing them locally to reinstate later - String focusField = dao.getJumpToField(); - String focusValue = dao.getJumpToValue(); - int limit = dao.getLimit(); - int offset = dao.getOffset(); - - dao.setJumpToField(null); - dao.setJumpToValue(null); - dao.setLimit(-1); - dao.setOffset(-1); - // perform the query and get the result int count = dao.doCountQuery(); - // now put back the values we removed for this method - dao.setJumpToField(focusField); - dao.setJumpToValue(focusValue); - dao.setLimit(limit); - dao.setOffset(offset); - dao.setCountValues(null); - log.debug(LogHelper.getHeader(context, "get_total_results_return", "return=" + count)); return count; diff --git a/dspace-api/src/main/java/org/dspace/browse/BrowseIndex.java b/dspace-api/src/main/java/org/dspace/browse/BrowseIndex.java index 0eddfcac91..ff6de08583 100644 --- a/dspace-api/src/main/java/org/dspace/browse/BrowseIndex.java +++ b/dspace-api/src/main/java/org/dspace/browse/BrowseIndex.java @@ -543,19 +543,6 @@ public class BrowseIndex { return getTableName(false, false, true, false); } - /** - * Get the name of the column that is used to store the default value column - * - * @return the name of the value column - */ - public String getValueColumn() { - if (!isDate()) { - return "sort_text_value"; - } else { - return "text_value"; - } - } - /** * Get the name of the primary key index column * @@ -565,35 +552,6 @@ public class BrowseIndex { return "id"; } - /** - * Is this browse index type for a title? - * - * @return true if title type, false if not - */ -// public boolean isTitle() -// { -// return "title".equals(getDataType()); -// } - - /** - * Is the browse index type for a date? - * - * @return true if date type, false if not - */ - public boolean isDate() { - return "date".equals(getDataType()); - } - - /** - * Is the browse index type for a plain text type? - * - * @return true if plain text type, false if not - */ -// public boolean isText() -// { -// return "text".equals(getDataType()); -// } - /** * Is the browse index of display type single? * diff --git a/dspace-api/src/main/java/org/dspace/browse/SolrBrowseDAO.java b/dspace-api/src/main/java/org/dspace/browse/SolrBrowseDAO.java index f99aab852b..1917dec423 100644 --- a/dspace-api/src/main/java/org/dspace/browse/SolrBrowseDAO.java +++ b/dspace-api/src/main/java/org/dspace/browse/SolrBrowseDAO.java @@ -13,6 +13,8 @@ import java.util.Collections; import java.util.Comparator; import java.util.List; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.Logger; import org.apache.solr.client.solrj.util.ClientUtils; @@ -180,18 +182,33 @@ public class SolrBrowseDAO implements BrowseDAO { addDefaultFilterQueries(query); if (distinct) { DiscoverFacetField dff; + + // To get the number of distinct values we use the next "json.facet" query param + // {"entries_count": {"type":"terms","field": "_filter", "limit":0, "numBuckets":true}}" + ObjectNode jsonFacet = JsonNodeFactory.instance.objectNode(); + ObjectNode entriesCount = JsonNodeFactory.instance.objectNode(); + entriesCount.put("type", "terms"); + entriesCount.put("field", facetField + "_filter"); + entriesCount.put("limit", 0); + entriesCount.put("numBuckets", true); + jsonFacet.set("entries_count", entriesCount); + if (StringUtils.isNotBlank(startsWith)) { dff = new DiscoverFacetField(facetField, - DiscoveryConfigurationParameters.TYPE_TEXT, -1, - DiscoveryConfigurationParameters.SORT.VALUE, startsWith); + DiscoveryConfigurationParameters.TYPE_TEXT, limit, + DiscoveryConfigurationParameters.SORT.VALUE, startsWith, offset); + + // Add the prefix to the json facet query + entriesCount.put("prefix", startsWith); } else { dff = new DiscoverFacetField(facetField, - DiscoveryConfigurationParameters.TYPE_TEXT, -1, - DiscoveryConfigurationParameters.SORT.VALUE); + DiscoveryConfigurationParameters.TYPE_TEXT, limit, + DiscoveryConfigurationParameters.SORT.VALUE, offset); } query.addFacetField(dff); query.setFacetMinCount(1); query.setMaxResults(0); + query.addProperty("json.facet", jsonFacet.toString()); } else { query.setMaxResults(limit/* > 0 ? limit : 20*/); if (offset > 0) { @@ -248,8 +265,7 @@ public class SolrBrowseDAO implements BrowseDAO { DiscoverResult resp = getSolrResponse(); int count = 0; if (distinct) { - List facetResults = resp.getFacetResult(facetField); - count = facetResults.size(); + count = (int) resp.getTotalEntries(); } else { // we need to cast to int to respect the BrowseDAO contract... count = (int) resp.getTotalSearchResults(); @@ -266,8 +282,8 @@ public class SolrBrowseDAO implements BrowseDAO { DiscoverResult resp = getSolrResponse(); List facet = resp.getFacetResult(facetField); int count = doCountQuery(); - int start = offset > 0 ? offset : 0; - int max = limit > 0 ? limit : count; //if negative, return everything + int start = 0; + int max = facet.size(); List result = new ArrayList<>(); if (ascending) { for (int i = start; i < (start + max) && i < count; i++) { diff --git a/dspace-api/src/main/java/org/dspace/content/ItemServiceImpl.java b/dspace-api/src/main/java/org/dspace/content/ItemServiceImpl.java index dc7820b669..bfa5670b3b 100644 --- a/dspace-api/src/main/java/org/dspace/content/ItemServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/content/ItemServiceImpl.java @@ -67,6 +67,7 @@ import org.dspace.event.Event; import org.dspace.harvest.HarvestedItem; import org.dspace.harvest.service.HarvestedItemService; import org.dspace.identifier.DOI; +import org.dspace.identifier.DOIIdentifierProvider; import org.dspace.identifier.IdentifierException; import org.dspace.identifier.service.DOIService; import org.dspace.identifier.service.IdentifierService; @@ -81,6 +82,9 @@ import org.dspace.orcid.service.OrcidTokenService; import org.dspace.profile.service.ResearcherProfileService; import org.dspace.qaevent.dao.QAEventsDAO; import org.dspace.services.ConfigurationService; +import org.dspace.versioning.Version; +import org.dspace.versioning.VersionHistory; +import org.dspace.versioning.service.VersionHistoryService; import org.dspace.versioning.service.VersioningService; import org.dspace.workflow.WorkflowItemService; import org.dspace.workflow.factory.WorkflowServiceFactory; @@ -176,6 +180,9 @@ public class ItemServiceImpl extends DSpaceObjectServiceImpl implements It @Autowired private QAEventsDAO qaEventsDao; + @Autowired + private VersionHistoryService versionHistoryService; + protected ItemServiceImpl() { } @@ -851,6 +858,7 @@ public class ItemServiceImpl extends DSpaceObjectServiceImpl implements It DOI doi = doiService.findDOIByDSpaceObject(context, item); if (doi != null) { doi.setDSpaceObject(null); + doi.setStatus(DOIIdentifierProvider.TO_BE_DELETED); } // remove version attached to the item @@ -1931,4 +1939,40 @@ prevent the generation of resource policy entry values with null dspace_object a } } + @Override + public boolean isLatestVersion(Context context, Item item) throws SQLException { + + VersionHistory history = versionHistoryService.findByItem(context, item); + if (history == null) { + // not all items have a version history + // if an item does not have a version history, it is by definition the latest + // version + return true; + } + + // start with the very latest version of the given item (may still be in + // workspace) + Version latestVersion = versionHistoryService.getLatestVersion(context, history); + + // find the latest version of the given item that is archived + while (latestVersion != null && !latestVersion.getItem().isArchived()) { + latestVersion = versionHistoryService.getPrevious(context, history, latestVersion); + } + + // could not find an archived version of the given item + if (latestVersion == null) { + // this scenario should never happen, but let's err on the side of showing too + // many items vs. to little + // (see discovery.xml, a lot of discovery configs filter out all items that are + // not the latest version) + return true; + } + + // sanity check + assert latestVersion.getItem().isArchived(); + + return item.equals(latestVersion.getItem()); + + } + } diff --git a/dspace-api/src/main/java/org/dspace/content/WorkspaceItemServiceImpl.java b/dspace-api/src/main/java/org/dspace/content/WorkspaceItemServiceImpl.java index 1da9e6e44a..543f5a55ef 100644 --- a/dspace-api/src/main/java/org/dspace/content/WorkspaceItemServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/content/WorkspaceItemServiceImpl.java @@ -178,6 +178,14 @@ public class WorkspaceItemServiceImpl implements WorkspaceItemService { @Override public WorkspaceItem create(Context c, WorkflowItem workflowItem) throws SQLException, AuthorizeException { + WorkspaceItem potentialDuplicate = findByItem(c, workflowItem.getItem()); + if (potentialDuplicate != null) { + throw new IllegalArgumentException(String.format( + "A workspace item referring to item %s already exists (%d)", + workflowItem.getItem().getID(), + potentialDuplicate.getID() + )); + } WorkspaceItem workspaceItem = workspaceItemDAO.create(c, new WorkspaceItem()); workspaceItem.setItem(workflowItem.getItem()); workspaceItem.setCollection(workflowItem.getCollection()); diff --git a/dspace-api/src/main/java/org/dspace/content/authority/DSpaceControlledVocabulary.java b/dspace-api/src/main/java/org/dspace/content/authority/DSpaceControlledVocabulary.java index 444332df97..4e30559e1c 100644 --- a/dspace-api/src/main/java/org/dspace/content/authority/DSpaceControlledVocabulary.java +++ b/dspace-api/src/main/java/org/dspace/content/authority/DSpaceControlledVocabulary.java @@ -8,6 +8,7 @@ package org.dspace.content.authority; import java.io.File; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -65,14 +66,17 @@ public class DSpaceControlledVocabulary extends SelfNamedPlugin implements Hiera protected static String labelTemplate = "//node[@label = '%s']"; protected static String idParentTemplate = "//node[@id = '%s']/parent::isComposedBy/parent::node"; protected static String rootTemplate = "/node"; + protected static String idAttribute = "id"; + protected static String labelAttribute = "label"; protected static String pluginNames[] = null; - protected String vocabularyName = null; protected InputSource vocabulary = null; protected Boolean suggestHierarchy = false; protected Boolean storeHierarchy = true; protected String hierarchyDelimiter = "::"; protected Integer preloadLevel = 1; + protected String valueAttribute = labelAttribute; + protected String valueTemplate = labelTemplate; public DSpaceControlledVocabulary() { super(); @@ -115,7 +119,7 @@ public class DSpaceControlledVocabulary extends SelfNamedPlugin implements Hiera } } - protected void init() { + protected void init(String locale) { if (vocabulary == null) { ConfigurationService config = DSpaceServicesFactory.getInstance().getConfigurationService(); @@ -125,13 +129,25 @@ public class DSpaceControlledVocabulary extends SelfNamedPlugin implements Hiera File.separator + "controlled-vocabularies" + File.separator; String configurationPrefix = "vocabulary.plugin." + vocabularyName; storeHierarchy = config.getBooleanProperty(configurationPrefix + ".hierarchy.store", storeHierarchy); + boolean storeIDs = config.getBooleanProperty(configurationPrefix + ".storeIDs", false); suggestHierarchy = config.getBooleanProperty(configurationPrefix + ".hierarchy.suggest", suggestHierarchy); preloadLevel = config.getIntProperty(configurationPrefix + ".hierarchy.preloadLevel", preloadLevel); String configuredDelimiter = config.getProperty(configurationPrefix + ".delimiter"); if (configuredDelimiter != null) { hierarchyDelimiter = configuredDelimiter.replaceAll("(^\"|\"$)", ""); } + if (storeIDs) { + valueAttribute = idAttribute; + valueTemplate = idTemplate; + } + String filename = vocabulariesPath + vocabularyName + ".xml"; + if (StringUtils.isNotEmpty(locale)) { + String localizedFilename = vocabulariesPath + vocabularyName + "_" + locale + ".xml"; + if (Paths.get(localizedFilename).toFile().exists()) { + filename = localizedFilename; + } + } log.info("Loading " + filename); vocabulary = new InputSource(filename); } @@ -144,9 +160,9 @@ public class DSpaceControlledVocabulary extends SelfNamedPlugin implements Hiera return (""); } else { String parentValue = buildString(node.getParentNode()); - Node currentLabel = node.getAttributes().getNamedItem("label"); - if (currentLabel != null) { - String currentValue = currentLabel.getNodeValue(); + Node currentNodeValue = node.getAttributes().getNamedItem(valueAttribute); + if (currentNodeValue != null) { + String currentValue = currentNodeValue.getNodeValue(); if (parentValue.equals("")) { return currentValue; } else { @@ -160,12 +176,13 @@ public class DSpaceControlledVocabulary extends SelfNamedPlugin implements Hiera @Override public Choices getMatches(String text, int start, int limit, String locale) { - init(); + init(locale); log.debug("Getting matches for '" + text + "'"); String xpathExpression = ""; String[] textHierarchy = text.split(hierarchyDelimiter, -1); for (int i = 0; i < textHierarchy.length; i++) { - xpathExpression += String.format(xpathTemplate, textHierarchy[i].replaceAll("'", "'").toLowerCase()); + xpathExpression += + String.format(xpathTemplate, textHierarchy[i].replaceAll("'", "'").toLowerCase()); } XPath xpath = XPathFactory.newInstance().newXPath(); int total = 0; @@ -184,12 +201,13 @@ public class DSpaceControlledVocabulary extends SelfNamedPlugin implements Hiera @Override public Choices getBestMatch(String text, String locale) { - init(); + init(locale); log.debug("Getting best matches for '" + text + "'"); String xpathExpression = ""; String[] textHierarchy = text.split(hierarchyDelimiter, -1); for (int i = 0; i < textHierarchy.length; i++) { - xpathExpression += String.format(labelTemplate, textHierarchy[i].replaceAll("'", "'")); + xpathExpression += + String.format(valueTemplate, textHierarchy[i].replaceAll("'", "'")); } XPath xpath = XPathFactory.newInstance().newXPath(); List choices = new ArrayList(); @@ -205,19 +223,19 @@ public class DSpaceControlledVocabulary extends SelfNamedPlugin implements Hiera @Override public String getLabel(String key, String locale) { - return getNodeLabel(key, this.suggestHierarchy); + return getNodeValue(key, locale, this.suggestHierarchy); } @Override public String getValue(String key, String locale) { - return getNodeLabel(key, this.storeHierarchy); + return getNodeValue(key, locale, this.storeHierarchy); } @Override public Choice getChoice(String authKey, String locale) { Node node; try { - node = getNode(authKey); + node = getNode(authKey, locale); } catch (XPathExpressionException e) { return null; } @@ -226,27 +244,27 @@ public class DSpaceControlledVocabulary extends SelfNamedPlugin implements Hiera @Override public boolean isHierarchical() { - init(); + init(null); return true; } @Override public Choices getTopChoices(String authorityName, int start, int limit, String locale) { - init(); + init(locale); String xpathExpression = rootTemplate; return getChoicesByXpath(xpathExpression, start, limit); } @Override public Choices getChoicesByParent(String authorityName, String parentId, int start, int limit, String locale) { - init(); + init(locale); String xpathExpression = String.format(idTemplate, parentId); return getChoicesByXpath(xpathExpression, start, limit); } @Override public Choice getParentChoice(String authorityName, String childId, String locale) { - init(); + init(locale); try { String xpathExpression = String.format(idParentTemplate, childId); Choice choice = createChoiceFromNode(getNodeFromXPath(xpathExpression)); @@ -259,7 +277,7 @@ public class DSpaceControlledVocabulary extends SelfNamedPlugin implements Hiera @Override public Integer getPreloadLevel() { - init(); + init(null); return preloadLevel; } @@ -270,8 +288,8 @@ public class DSpaceControlledVocabulary extends SelfNamedPlugin implements Hiera return false; } - private Node getNode(String key) throws XPathExpressionException { - init(); + private Node getNode(String key, String locale) throws XPathExpressionException { + init(locale); String xpathExpression = String.format(idTemplate, key); Node node = getNodeFromXPath(xpathExpression); return node; @@ -319,16 +337,16 @@ public class DSpaceControlledVocabulary extends SelfNamedPlugin implements Hiera return extras; } - private String getNodeLabel(String key, boolean useHierarchy) { + private String getNodeValue(String key, String locale, boolean useHierarchy) { try { - Node node = getNode(key); + Node node = getNode(key, locale); if (Objects.isNull(node)) { return null; } if (useHierarchy) { return this.buildString(node); } else { - return node.getAttributes().getNamedItem("label").getNodeValue(); + return node.getAttributes().getNamedItem(valueAttribute).getNodeValue(); } } catch (XPathExpressionException e) { return (""); @@ -349,7 +367,7 @@ public class DSpaceControlledVocabulary extends SelfNamedPlugin implements Hiera if (this.storeHierarchy) { return hierarchy; } else { - return node.getAttributes().getNamedItem("label").getNodeValue(); + return node.getAttributes().getNamedItem(valueAttribute).getNodeValue(); } } diff --git a/dspace-api/src/main/java/org/dspace/content/service/ItemService.java b/dspace-api/src/main/java/org/dspace/content/service/ItemService.java index 47d2d5bdaa..3fea75665b 100644 --- a/dspace-api/src/main/java/org/dspace/content/service/ItemService.java +++ b/dspace-api/src/main/java/org/dspace/content/service/ItemService.java @@ -1009,4 +1009,14 @@ public interface ItemService */ EntityType getEntityType(Context context, Item item) throws SQLException; + + /** + * Check whether the given item is the latest version. If the latest item cannot + * be determined, because either the version history or the latest version is + * not present, assume the item is latest. + * @param context the DSpace context. + * @param item the item that should be checked. + * @return true if the item is the latest version, false otherwise. + */ + public boolean isLatestVersion(Context context, Item item) throws SQLException; } diff --git a/dspace-api/src/main/java/org/dspace/core/AbstractHibernateDAO.java b/dspace-api/src/main/java/org/dspace/core/AbstractHibernateDAO.java index b6db81662f..b4347f0969 100644 --- a/dspace-api/src/main/java/org/dspace/core/AbstractHibernateDAO.java +++ b/dspace-api/src/main/java/org/dspace/core/AbstractHibernateDAO.java @@ -313,7 +313,7 @@ public abstract class AbstractHibernateDAO implements GenericDAO { org.hibernate.query.Query hquery = query.unwrap(org.hibernate.query.Query.class); Stream stream = hquery.stream(); Iterator iter = stream.iterator(); - return new AbstractIterator () { + return new AbstractIterator() { @Override protected T computeNext() { return iter.hasNext() ? iter.next() : endOfData(); diff --git a/dspace-api/src/main/java/org/dspace/core/Context.java b/dspace-api/src/main/java/org/dspace/core/Context.java index 877b7a0055..9ba80ead6a 100644 --- a/dspace-api/src/main/java/org/dspace/core/Context.java +++ b/dspace-api/src/main/java/org/dspace/core/Context.java @@ -883,7 +883,19 @@ public class Context implements AutoCloseable { } /** - * Remove an entity from the cache. This is necessary when batch processing a large number of items. + * Remove all entities from the cache and reload the current user entity. This is useful when batch processing + * a large number of entities when the calling code requires the cache to be completely cleared before continuing. + * + * @throws SQLException if a database error occurs. + */ + public void uncacheEntities() throws SQLException { + dbConnection.uncacheEntities(); + reloadContextBoundEntities(); + } + + /** + * Remove an entity from the cache. This is useful when batch processing a large number of entities + * when the calling code needs to retain some items in the cache while removing others. * * @param entity The entity to reload * @param The class of the entity. The entity must implement the {@link ReloadableEntity} interface. diff --git a/dspace-api/src/main/java/org/dspace/core/DBConnection.java b/dspace-api/src/main/java/org/dspace/core/DBConnection.java index 66e4a65dbf..c9c4ce0953 100644 --- a/dspace-api/src/main/java/org/dspace/core/DBConnection.java +++ b/dspace-api/src/main/java/org/dspace/core/DBConnection.java @@ -124,28 +124,38 @@ public interface DBConnection { public long getCacheSize() throws SQLException; /** - * Reload a DSpace object from the database. This will make sure the object + * Reload an entity from the database. This will make sure the object * is valid and stored in the cache. The returned object should be used * henceforth instead of the passed object. * - * @param type of {@link entity} - * @param entity The DSpace object to reload + * @param type of entity. + * @param entity The entity to reload. * @return the reloaded entity. - * @throws java.sql.SQLException passed through. + * @throws SQLException passed through. */ public E reloadEntity(E entity) throws SQLException; /** - * Remove a DSpace object from the session cache when batch processing a - * large number of objects. + * Remove all entities from the session cache. * - *

Objects removed from cache are not saved in any way. Therefore, if you - * have modified an object, you should be sure to {@link commit()} changes + *

Entities removed from cache are not saved in any way. Therefore, if you + * have modified any entities, you should be sure to {@link #commit()} changes * before calling this method. * - * @param Type of {@link entity} - * @param entity The DSpace object to decache. - * @throws java.sql.SQLException passed through. + * @throws SQLException passed through. + */ + public void uncacheEntities() throws SQLException; + + /** + * Remove an entity from the session cache. + * + *

Entities removed from cache are not saved in any way. Therefore, if you + * have modified the entity, you should be sure to {@link #commit()} changes + * before calling this method. + * + * @param Type of entity. + * @param entity The entity to decache. + * @throws SQLException passed through. */ public void uncacheEntity(E entity) throws SQLException; diff --git a/dspace-api/src/main/java/org/dspace/core/HibernateDBConnection.java b/dspace-api/src/main/java/org/dspace/core/HibernateDBConnection.java index a867849077..806930d036 100644 --- a/dspace-api/src/main/java/org/dspace/core/HibernateDBConnection.java +++ b/dspace-api/src/main/java/org/dspace/core/HibernateDBConnection.java @@ -242,6 +242,11 @@ public class HibernateDBConnection implements DBConnection { } } + @Override + public void uncacheEntities() throws SQLException { + getSession().clear(); + } + /** * Evict an entity from the hibernate cache. *

diff --git a/dspace-api/src/main/java/org/dspace/ctask/general/BasicLinkChecker.java b/dspace-api/src/main/java/org/dspace/ctask/general/BasicLinkChecker.java index 756d65a0ad..9bd08bffdb 100644 --- a/dspace-api/src/main/java/org/dspace/ctask/general/BasicLinkChecker.java +++ b/dspace-api/src/main/java/org/dspace/ctask/general/BasicLinkChecker.java @@ -19,6 +19,8 @@ import org.dspace.content.Item; import org.dspace.content.MetadataValue; import org.dspace.curate.AbstractCurationTask; import org.dspace.curate.Curator; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; /** * A basic link checker that is designed to be extended. By default this link checker @@ -42,6 +44,9 @@ public class BasicLinkChecker extends AbstractCurationTask { // The log4j logger for this class private static Logger log = org.apache.logging.log4j.LogManager.getLogger(BasicLinkChecker.class); + protected static final ConfigurationService configurationService + = DSpaceServicesFactory.getInstance().getConfigurationService(); + /** * Perform the link checking. @@ -110,7 +115,8 @@ public class BasicLinkChecker extends AbstractCurationTask { */ protected boolean checkURL(String url, StringBuilder results) { // Link check the URL - int httpStatus = getResponseStatus(url); + int redirects = 0; + int httpStatus = getResponseStatus(url, redirects); if ((httpStatus >= 200) && (httpStatus < 300)) { results.append(" - " + url + " = " + httpStatus + " - OK\n"); @@ -128,14 +134,24 @@ public class BasicLinkChecker extends AbstractCurationTask { * @param url The url to open * @return The HTTP response code (e.g. 200 / 301 / 404 / 500) */ - protected int getResponseStatus(String url) { + protected int getResponseStatus(String url, int redirects) { try { URL theURL = new URL(url); HttpURLConnection connection = (HttpURLConnection) theURL.openConnection(); - int code = connection.getResponseCode(); - connection.disconnect(); + connection.setInstanceFollowRedirects(true); + int statusCode = connection.getResponseCode(); + int maxRedirect = configurationService.getIntProperty("curate.checklinks.max-redirect", 0); + if ((statusCode == HttpURLConnection.HTTP_MOVED_TEMP || statusCode == HttpURLConnection.HTTP_MOVED_PERM || + statusCode == HttpURLConnection.HTTP_SEE_OTHER)) { + connection.disconnect(); + String newUrl = connection.getHeaderField("Location"); + if (newUrl != null && (maxRedirect >= redirects || maxRedirect == -1)) { + redirects++; + return getResponseStatus(newUrl, redirects); + } - return code; + } + return statusCode; } catch (IOException ioe) { // Must be a bad URL diff --git a/dspace-api/src/main/java/org/dspace/ctask/general/ClamScan.java b/dspace-api/src/main/java/org/dspace/ctask/general/ClamScan.java index 47fa6ee645..5a717fe1e4 100644 --- a/dspace-api/src/main/java/org/dspace/ctask/general/ClamScan.java +++ b/dspace-api/src/main/java/org/dspace/ctask/general/ClamScan.java @@ -15,13 +15,12 @@ import java.io.InputStream; import java.net.InetSocketAddress; import java.net.Socket; import java.net.SocketException; -import java.sql.SQLException; import java.util.ArrayList; import java.util.List; +import org.apache.commons.collections4.ListUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.dspace.authorize.AuthorizeException; import org.dspace.content.Bitstream; import org.dspace.content.Bundle; import org.dspace.content.DSpaceObject; @@ -99,8 +98,13 @@ public class ClamScan extends AbstractCurationTask { } try { - Bundle bundle = itemService.getBundles(item, "ORIGINAL").get(0); - results = new ArrayList<>(); + List bundles = itemService.getBundles(item, "ORIGINAL"); + if (ListUtils.emptyIfNull(bundles).isEmpty()) { + setResult("No ORIGINAL bundle found for item: " + getItemHandle(item)); + return Curator.CURATE_SKIP; + } + Bundle bundle = bundles.get(0); + results = new ArrayList(); for (Bitstream bitstream : bundle.getBitstreams()) { InputStream inputstream = bitstreamService.retrieve(Curator.curationContext(), bitstream); logDebugMessage("Scanning " + bitstream.getName() + " . . . "); @@ -121,10 +125,11 @@ public class ClamScan extends AbstractCurationTask { } } - } catch (AuthorizeException authE) { - throw new IOException(authE.getMessage(), authE); - } catch (SQLException sqlE) { - throw new IOException(sqlE.getMessage(), sqlE); + } catch (Exception e) { + // Any exception which may occur during the performance of the task should be caught here + // And end the process gracefully + log.error("Error scanning item: " + getItemHandle(item), e); + status = Curator.CURATE_ERROR; } finally { closeSession(); } diff --git a/dspace-api/src/main/java/org/dspace/ctask/general/CreateMissingIdentifiers.java b/dspace-api/src/main/java/org/dspace/ctask/general/CreateMissingIdentifiers.java index 9639461426..0734d60946 100644 --- a/dspace-api/src/main/java/org/dspace/ctask/general/CreateMissingIdentifiers.java +++ b/dspace-api/src/main/java/org/dspace/ctask/general/CreateMissingIdentifiers.java @@ -10,6 +10,7 @@ package org.dspace.ctask.general; import java.io.IOException; import java.sql.SQLException; +import java.util.List; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -25,7 +26,6 @@ import org.dspace.identifier.IdentifierProvider; import org.dspace.identifier.VersionedHandleIdentifierProviderWithCanonicalHandles; import org.dspace.identifier.factory.IdentifierServiceFactory; import org.dspace.identifier.service.IdentifierService; -import org.dspace.services.factory.DSpaceServicesFactory; /** * Ensure that an object has all of the identifiers that it should, minting them @@ -45,20 +45,6 @@ public class CreateMissingIdentifiers return Curator.CURATE_SKIP; } - // XXX Temporary escape when an incompatible provider is configured. - // XXX Remove this when the provider is fixed. - boolean compatible = DSpaceServicesFactory - .getInstance() - .getServiceManager() - .getServiceByName( - VersionedHandleIdentifierProviderWithCanonicalHandles.class.getCanonicalName(), - IdentifierProvider.class) == null; - if (!compatible) { - setResult("This task is not compatible with VersionedHandleIdentifierProviderWithCanonicalHandles"); - return Curator.CURATE_ERROR; - } - // XXX End of escape - String typeText = Constants.typeText[dso.getType()]; // Get a Context @@ -75,6 +61,18 @@ public class CreateMissingIdentifiers .getInstance() .getIdentifierService(); + // XXX Temporary escape when an incompatible provider is configured. + // XXX Remove this when the provider is fixed. + List providerList = identifierService.getProviders(); + boolean compatible = + providerList.stream().noneMatch(p -> p instanceof VersionedHandleIdentifierProviderWithCanonicalHandles); + + if (!compatible) { + setResult("This task is not compatible with VersionedHandleIdentifierProviderWithCanonicalHandles"); + return Curator.CURATE_ERROR; + } + // XXX End of escape + // Register any missing identifiers. try { identifierService.register(context, dso); diff --git a/dspace-api/src/main/java/org/dspace/curate/Curation.java b/dspace-api/src/main/java/org/dspace/curate/Curation.java index 4d70286e79..625692a866 100644 --- a/dspace-api/src/main/java/org/dspace/curate/Curation.java +++ b/dspace-api/src/main/java/org/dspace/curate/Curation.java @@ -165,7 +165,7 @@ public class Curation extends DSpaceRunnable { * End of curation script; logs script time if -v verbose is set * * @param timeRun Time script was started - * @throws SQLException If DSpace contextx can't complete + * @throws SQLException If DSpace context can't complete */ private void endScript(long timeRun) throws SQLException { context.complete(); @@ -185,7 +185,7 @@ public class Curation extends DSpaceRunnable { Curator curator = new Curator(handler); OutputStream reporterStream; if (null == this.reporter) { - reporterStream = new NullOutputStream(); + reporterStream = NullOutputStream.NULL_OUTPUT_STREAM; } else if ("-".equals(this.reporter)) { reporterStream = System.out; } else { @@ -300,9 +300,17 @@ public class Curation extends DSpaceRunnable { // scope if (this.commandLine.getOptionValue('s') != null) { this.scope = this.commandLine.getOptionValue('s'); - if (this.scope != null && Curator.TxScope.valueOf(this.scope.toUpperCase()) == null) { - this.handler.logError("Bad transaction scope '" + this.scope + "': only 'object', 'curation' or " + - "'open' recognized"); + boolean knownScope; + try { + Curator.TxScope.valueOf(this.scope.toUpperCase()); + knownScope = true; + } catch (IllegalArgumentException | NullPointerException e) { + knownScope = false; + } + if (!knownScope) { + this.handler.logError("Bad transaction scope '" + + this.scope + + "': only 'object', 'curation' or 'open' recognized"); throw new IllegalArgumentException( "Bad transaction scope '" + this.scope + "': only 'object', 'curation' or " + "'open' recognized"); diff --git a/dspace-api/src/main/java/org/dspace/discovery/DiscoverResult.java b/dspace-api/src/main/java/org/dspace/discovery/DiscoverResult.java index 00236d2bfe..a56804e3e7 100644 --- a/dspace-api/src/main/java/org/dspace/discovery/DiscoverResult.java +++ b/dspace-api/src/main/java/org/dspace/discovery/DiscoverResult.java @@ -32,6 +32,9 @@ public class DiscoverResult { private List indexableObjects; private Map> facetResults; + // Total count of facet entries calculated for a metadata browsing query + private long totalEntries; + /** * A map that contains all the documents sougth after, the key is a string representation of the Indexable Object */ @@ -64,6 +67,14 @@ public class DiscoverResult { this.totalSearchResults = totalSearchResults; } + public long getTotalEntries() { + return totalEntries; + } + + public void setTotalEntries(long totalEntries) { + this.totalEntries = totalEntries; + } + public int getStart() { return start; } diff --git a/dspace-api/src/main/java/org/dspace/discovery/IndexClient.java b/dspace-api/src/main/java/org/dspace/discovery/IndexClient.java index 3479c25bf3..948374d4c8 100644 --- a/dspace-api/src/main/java/org/dspace/discovery/IndexClient.java +++ b/dspace-api/src/main/java/org/dspace/discovery/IndexClient.java @@ -40,14 +40,13 @@ import org.dspace.services.factory.DSpaceServicesFactory; import org.dspace.utils.DSpace; /** - * Class used to reindex dspace communities/collections/items into discovery + * Class used to reindex DSpace communities/collections/items into discovery. */ public class IndexClient extends DSpaceRunnable { private Context context; private IndexingService indexer = DSpaceServicesFactory.getInstance().getServiceManager() - .getServiceByName(IndexingService.class.getName(), - IndexingService.class); + .getServiceByName(IndexingService.class.getName(), IndexingService.class); private IndexClientOptions indexClientOptions; @@ -69,103 +68,80 @@ public class IndexClient extends DSpaceRunnable indexableObject = Optional.empty(); if (indexClientOptions == IndexClientOptions.REMOVE || indexClientOptions == IndexClientOptions.INDEX) { - final String param = indexClientOptions == IndexClientOptions.REMOVE ? commandLine.getOptionValue('r') : - commandLine.getOptionValue('i'); - UUID uuid = null; - try { - uuid = UUID.fromString(param); - } catch (Exception e) { - // nothing to do, it should be a handle - } - - if (uuid != null) { - final Item item = ContentServiceFactory.getInstance().getItemService().find(context, uuid); - if (item != null) { - indexableObject = Optional.of(new IndexableItem(item)); - } else { - // it could be a community - final Community community = ContentServiceFactory.getInstance(). - getCommunityService().find(context, uuid); - if (community != null) { - indexableObject = Optional.of(new IndexableCommunity(community)); - } else { - // it could be a collection - final Collection collection = ContentServiceFactory.getInstance(). - getCollectionService().find(context, uuid); - if (collection != null) { - indexableObject = Optional.of(new IndexableCollection(collection)); - } - } - } - } else { - final DSpaceObject dso = HandleServiceFactory.getInstance() - .getHandleService().resolveToObject(context, param); - if (dso != null) { - final IndexFactory indexableObjectService = IndexObjectFactoryFactory.getInstance(). - getIndexFactoryByType(Constants.typeText[dso.getType()]); - indexableObject = indexableObjectService.findIndexableObject(context, dso.getID().toString()); - } - } + final String param = indexClientOptions == IndexClientOptions.REMOVE ? commandLine.getOptionValue('r') + : commandLine.getOptionValue('i'); + indexableObject = resolveIndexableObject(context, param); if (!indexableObject.isPresent()) { throw new IllegalArgumentException("Cannot resolve " + param + " to a DSpace object"); } } - if (indexClientOptions == IndexClientOptions.REMOVE) { - handler.logInfo("Removing " + commandLine.getOptionValue("r") + " from Index"); - indexer.unIndexContent(context, indexableObject.get().getUniqueIndexID()); - } else if (indexClientOptions == IndexClientOptions.CLEAN) { - handler.logInfo("Cleaning Index"); - indexer.cleanIndex(); - } else if (indexClientOptions == IndexClientOptions.DELETE) { - handler.logInfo("Deleting Index"); - indexer.deleteIndex(); - } else if (indexClientOptions == IndexClientOptions.BUILD || - indexClientOptions == IndexClientOptions.BUILDANDSPELLCHECK) { - handler.logInfo("(Re)building index from scratch."); - if (StringUtils.isNotBlank(type)) { - handler.logWarning(String.format("Type option, %s, not applicable for entire index rebuild option, b" + - ", type will be ignored", TYPE_OPTION)); - } - indexer.deleteIndex(); - indexer.createIndex(context); - if (indexClientOptions == IndexClientOptions.BUILDANDSPELLCHECK) { + switch (indexClientOptions) { + case REMOVE: + handler.logInfo("Removing " + commandLine.getOptionValue("r") + " from Index"); + indexer.unIndexContent(context, indexableObject.get().getUniqueIndexID()); + break; + case CLEAN: + handler.logInfo("Cleaning Index"); + indexer.cleanIndex(); + break; + case DELETE: + handler.logInfo("Deleting Index"); + indexer.deleteIndex(); + break; + case BUILD: + case BUILDANDSPELLCHECK: + handler.logInfo("(Re)building index from scratch."); + if (StringUtils.isNotBlank(type)) { + handler.logWarning(String.format( + "Type option, %s, not applicable for entire index rebuild option, b" + + ", type will be ignored", + TYPE_OPTION)); + } + indexer.deleteIndex(); + indexer.createIndex(context); + if (indexClientOptions == IndexClientOptions.BUILDANDSPELLCHECK) { + checkRebuildSpellCheck(commandLine, indexer); + } + break; + case OPTIMIZE: + handler.logInfo("Optimizing search core."); + indexer.optimize(); + break; + case SPELLCHECK: checkRebuildSpellCheck(commandLine, indexer); - } - } else if (indexClientOptions == IndexClientOptions.OPTIMIZE) { - handler.logInfo("Optimizing search core."); - indexer.optimize(); - } else if (indexClientOptions == IndexClientOptions.SPELLCHECK) { - checkRebuildSpellCheck(commandLine, indexer); - } else if (indexClientOptions == IndexClientOptions.INDEX) { - handler.logInfo("Indexing " + commandLine.getOptionValue('i') + " force " + commandLine.hasOption("f")); - final long startTimeMillis = System.currentTimeMillis(); - final long count = indexAll(indexer, ContentServiceFactory.getInstance(). - getItemService(), context, indexableObject.get()); - final long seconds = (System.currentTimeMillis() - startTimeMillis) / 1000; - handler.logInfo("Indexed " + count + " object" + (count > 1 ? "s" : "") + " in " + seconds + " seconds"); - } else if (indexClientOptions == IndexClientOptions.UPDATE || - indexClientOptions == IndexClientOptions.UPDATEANDSPELLCHECK) { - handler.logInfo("Updating Index"); - indexer.updateIndex(context, false, type); - if (indexClientOptions == IndexClientOptions.UPDATEANDSPELLCHECK) { - checkRebuildSpellCheck(commandLine, indexer); - } - } else if (indexClientOptions == IndexClientOptions.FORCEUPDATE || - indexClientOptions == IndexClientOptions.FORCEUPDATEANDSPELLCHECK) { - handler.logInfo("Updating Index"); - indexer.updateIndex(context, true, type); - if (indexClientOptions == IndexClientOptions.FORCEUPDATEANDSPELLCHECK) { - checkRebuildSpellCheck(commandLine, indexer); - } + break; + case INDEX: + handler.logInfo("Indexing " + commandLine.getOptionValue('i') + " force " + commandLine.hasOption("f")); + final long startTimeMillis = System.currentTimeMillis(); + final long count = indexAll(indexer, ContentServiceFactory.getInstance().getItemService(), context, + indexableObject.get()); + final long seconds = (System.currentTimeMillis() - startTimeMillis) / 1000; + handler.logInfo("Indexed " + count + " object" + (count > 1 ? "s" : "") + + " in " + seconds + " seconds"); + break; + case UPDATE: + case UPDATEANDSPELLCHECK: + handler.logInfo("Updating Index"); + indexer.updateIndex(context, false, type); + if (indexClientOptions == IndexClientOptions.UPDATEANDSPELLCHECK) { + checkRebuildSpellCheck(commandLine, indexer); + } + break; + case FORCEUPDATE: + case FORCEUPDATEANDSPELLCHECK: + handler.logInfo("Updating Index"); + indexer.updateIndex(context, true, type); + if (indexClientOptions == IndexClientOptions.FORCEUPDATEANDSPELLCHECK) { + checkRebuildSpellCheck(commandLine, indexer); + } + break; + default: + handler.handleException("Invalid index client option."); + break; } handler.logInfo("Done with indexing"); @@ -174,7 +150,7 @@ public class IndexClient extends DSpaceRunnable resolveIndexableObject(Context context, String param) throws SQLException { + UUID uuid = null; + try { + uuid = UUID.fromString(param); + } catch (Exception e) { + // It's not a UUID, proceed to treat it as a handle. } - return count; + if (uuid != null) { + Item item = ContentServiceFactory.getInstance().getItemService().find(context, uuid); + if (item != null) { + return Optional.of(new IndexableItem(item)); + } + Community community = ContentServiceFactory.getInstance().getCommunityService().find(context, uuid); + if (community != null) { + return Optional.of(new IndexableCommunity(community)); + } + Collection collection = ContentServiceFactory.getInstance().getCollectionService().find(context, uuid); + if (collection != null) { + return Optional.of(new IndexableCollection(collection)); + } + } else { + DSpaceObject dso = HandleServiceFactory.getInstance().getHandleService().resolveToObject(context, param); + if (dso != null) { + IndexFactory indexableObjectService = IndexObjectFactoryFactory.getInstance() + .getIndexFactoryByType(Constants.typeText[dso.getType()]); + return indexableObjectService.findIndexableObject(context, dso.getID().toString()); + } + } + return Optional.empty(); } /** - * Indexes all items in the given collection. + * Indexes the given object and all its children recursively. * - * @param indexingService - * @param itemService + * @param indexingService The indexing service. + * @param itemService The item service. * @param context The relevant DSpace Context. - * @param collection collection to index - * @throws IOException A general class of exceptions produced by failed or interrupted I/O operations. - * @throws SearchServiceException in case of a solr exception - * @throws SQLException An exception that provides information on a database access error or other errors. + * @param indexableObject The IndexableObject to index recursively. + * @return The count of indexed objects. + * @throws IOException If I/O error occurs. + * @throws SearchServiceException If a search service error occurs. + * @throws SQLException If database error occurs. */ - private static long indexItems(final IndexingService indexingService, - final ItemService itemService, - final Context context, - final Collection collection) - throws IOException, SearchServiceException, SQLException { + private long indexAll(final IndexingService indexingService, final ItemService itemService, final Context context, + final IndexableObject indexableObject) throws IOException, SearchServiceException, SQLException { long count = 0; - final Iterator itemIterator = itemService.findByCollection(context, collection); - while (itemIterator.hasNext()) { - Item item = itemIterator.next(); - indexingService.indexContent(context, new IndexableItem(item), true, false); - count++; - //To prevent memory issues, discard an object from the cache after processing - context.uncacheEntity(item); + boolean commit = indexableObject instanceof IndexableCommunity || + indexableObject instanceof IndexableCollection; + indexingService.indexContent(context, indexableObject, true, commit); + count++; + + if (indexableObject instanceof IndexableCommunity) { + final Community community = (Community) indexableObject.getIndexedObject(); + final String communityHandle = community.getHandle(); + for (final Community subcommunity : community.getSubcommunities()) { + count += indexAll(indexingService, itemService, context, new IndexableCommunity(subcommunity)); + context.uncacheEntity(subcommunity); + } + // Reload community to get up-to-date collections + final Community reloadedCommunity = (Community) HandleServiceFactory.getInstance().getHandleService() + .resolveToObject(context, communityHandle); + for (final Collection collection : reloadedCommunity.getCollections()) { + count += indexAll(indexingService, itemService, context, new IndexableCollection(collection)); + context.uncacheEntity(collection); + } + } else if (indexableObject instanceof IndexableCollection) { + final Collection collection = (Collection) indexableObject.getIndexedObject(); + final Iterator itemIterator = itemService.findByCollection(context, collection); + while (itemIterator.hasNext()) { + Item item = itemIterator.next(); + indexingService.indexContent(context, new IndexableItem(item), true, false); + count++; + context.uncacheEntity(item); + } + indexingService.commit(); } - indexingService.commit(); return count; } @@ -268,10 +259,10 @@ public class IndexClient extends DSpaceRunnablejson.facet parameter with the following value: + * + *


+     * {
+     *     "entries_count": {
+     *         "type": "terms",
+     *         "field": "facetNameField_filter",
+     *         "limit": 0,
+     *         "prefix": "prefix_value",
+     *         "numBuckets": true
+     *     }
+     * }
+     * 
+ * + * This value is returned in the facets field of the Solr response. + * + * @param result DiscoverResult object where the total entries count will be stored + * @param solrQueryResponse QueryResponse object containing the solr response + */ + private void resolveEntriesCount(DiscoverResult result, QueryResponse solrQueryResponse) { + NestableJsonFacet response = solrQueryResponse.getJsonFacetingResponse(); + if (response != null) { + BucketBasedJsonFacet facet = response.getBucketBasedFacets("entries_count"); + if (facet != null) { + result.setTotalEntries(facet.getNumBucketsCount()); + } + } + } private void resolveFacetFields(Context context, DiscoverQuery query, DiscoverResult result, boolean skipLoadingResponse, QueryResponse solrQueryResponse) throws SQLException { @@ -1411,8 +1445,6 @@ public class SolrServiceImpl implements SearchService, IndexingService { } else { return field + "_acid"; } - } else if (facetFieldConfig.getType().equals(DiscoveryConfigurationParameters.TYPE_STANDARD)) { - return field; } else { return field; } diff --git a/dspace-api/src/main/java/org/dspace/discovery/indexobject/IndexFactoryImpl.java b/dspace-api/src/main/java/org/dspace/discovery/indexobject/IndexFactoryImpl.java index f1ae137b91..c9a865ec85 100644 --- a/dspace-api/src/main/java/org/dspace/discovery/indexobject/IndexFactoryImpl.java +++ b/dspace-api/src/main/java/org/dspace/discovery/indexobject/IndexFactoryImpl.java @@ -118,20 +118,10 @@ public abstract class IndexFactoryImpl implements ParseContext tikaContext = new ParseContext(); // Use Apache Tika to parse the full text stream(s) + boolean extractionSucceeded = false; try (InputStream fullTextStreams = streams.getStream()) { tikaParser.parse(fullTextStreams, tikaHandler, tikaMetadata, tikaContext); - - // Write Tika metadata to "tika_meta_*" fields. - // This metadata is not very useful right now, - // but we'll keep it just in case it becomes more useful. - for (String name : tikaMetadata.names()) { - for (String value : tikaMetadata.getValues(name)) { - doc.addField("tika_meta_" + name, value); - } - } - - // Save (parsed) full text to "fulltext" field - doc.addField("fulltext", tikaHandler.toString()); + extractionSucceeded = true; } catch (SAXException saxe) { // Check if this SAXException is just a notice that this file was longer than the character limit. // Unfortunately there is not a unique, public exception type to catch here. This error is thrown @@ -141,6 +131,7 @@ public abstract class IndexFactoryImpl implements // log that we only indexed up to that configured limit log.info("Full text is larger than the configured limit (discovery.solr.fulltext.charLimit)." + " Only the first {} characters were indexed.", charLimit); + extractionSucceeded = true; } else { log.error("Tika parsing error. Could not index full text.", saxe); throw new IOException("Tika parsing error. Could not index full text.", saxe); @@ -148,11 +139,19 @@ public abstract class IndexFactoryImpl implements } catch (TikaException | IOException ex) { log.error("Tika parsing error. Could not index full text.", ex); throw new IOException("Tika parsing error. Could not index full text.", ex); - } finally { - // Add document to index - solr.add(doc); } - return; + if (extractionSucceeded) { + // Write Tika metadata to "tika_meta_*" fields. + // This metadata is not very useful right now, + // but we'll keep it just in case it becomes more useful. + for (String name : tikaMetadata.names()) { + for (String value : tikaMetadata.getValues(name)) { + doc.addField("tika_meta_" + name, value); + } + } + // Save (parsed) full text to "fulltext" field + doc.addField("fulltext", tikaHandler.toString()); + } } // Add document to index solr.add(doc); diff --git a/dspace-api/src/main/java/org/dspace/discovery/indexobject/ItemIndexFactoryImpl.java b/dspace-api/src/main/java/org/dspace/discovery/indexobject/ItemIndexFactoryImpl.java index 7cdb8b93d8..a7a7557496 100644 --- a/dspace-api/src/main/java/org/dspace/discovery/indexobject/ItemIndexFactoryImpl.java +++ b/dspace-api/src/main/java/org/dspace/discovery/indexobject/ItemIndexFactoryImpl.java @@ -67,8 +67,6 @@ import org.dspace.handle.service.HandleService; import org.dspace.services.factory.DSpaceServicesFactory; import org.dspace.util.MultiFormatDateParser; import org.dspace.util.SolrUtils; -import org.dspace.versioning.Version; -import org.dspace.versioning.VersionHistory; import org.dspace.versioning.service.VersionHistoryService; import org.dspace.xmlworkflow.storedcomponents.XmlWorkflowItem; import org.dspace.xmlworkflow.storedcomponents.service.XmlWorkflowItemService; @@ -151,12 +149,14 @@ public class ItemIndexFactoryImpl extends DSpaceObjectIndexFactoryImpl implements /** - * Regenerate the group cache AKA the group2groupcache table in the database - - * meant to be called when a group is added or removed from another group + * Returns a set with pairs of parent and child group UUIDs, representing the new cache table rows. * - * @param context The relevant DSpace Context. - * @param flushQueries flushQueries Flush all pending queries + * @param context The relevant DSpace Context. + * @param flushQueries flushQueries Flush all pending queries + * @return Pairs of parent and child group UUID of the new cache. * @throws SQLException An exception that provides information on a database access error or other errors. */ - protected void rethinkGroupCache(Context context, boolean flushQueries) throws SQLException { - + private Set> computeNewCache(Context context, boolean flushQueries) throws SQLException { Map> parents = new HashMap<>(); List> group2groupResults = groupDAO.getGroup2GroupResults(context, flushQueries); @@ -689,19 +689,8 @@ public class GroupServiceImpl extends DSpaceObjectServiceImpl implements UUID parent = group2groupResult.getLeft(); UUID child = group2groupResult.getRight(); - // if parent doesn't have an entry, create one - if (!parents.containsKey(parent)) { - Set children = new HashSet<>(); - - // add child id to the list - children.add(child); - parents.put(parent, children); - } else { - // parent has an entry, now add the child to the parent's record - // of children - Set children = parents.get(parent); - children.add(child); - } + parents.putIfAbsent(parent, new HashSet<>()); + parents.get(parent).add(child); } // now parents is a hash of all of the IDs of groups that are parents @@ -714,28 +703,43 @@ public class GroupServiceImpl extends DSpaceObjectServiceImpl implements parent.getValue().addAll(myChildren); } - // empty out group2groupcache table - group2GroupCacheDAO.deleteAll(context); - - // write out new one + // write out new cache IN MEMORY ONLY and returns it + Set> newCache = new HashSet<>(); for (Map.Entry> parent : parents.entrySet()) { UUID key = parent.getKey(); - for (UUID child : parent.getValue()) { - - Group parentGroup = find(context, key); - Group childGroup = find(context, child); - - - if (parentGroup != null && childGroup != null && group2GroupCacheDAO - .find(context, parentGroup, childGroup) == null) { - Group2GroupCache group2GroupCache = group2GroupCacheDAO.create(context, new Group2GroupCache()); - group2GroupCache.setParent(parentGroup); - group2GroupCache.setChild(childGroup); - group2GroupCacheDAO.save(context, group2GroupCache); - } + newCache.add(Pair.of(key, child)); } } + return newCache; + } + + + /** + * Regenerate the group cache AKA the group2groupcache table in the database - + * meant to be called when a group is added or removed from another group + * + * @param context The relevant DSpace Context. + * @param flushQueries flushQueries Flush all pending queries + * @throws SQLException An exception that provides information on a database access error or other errors. + */ + protected void rethinkGroupCache(Context context, boolean flushQueries) throws SQLException { + // current cache in the database + Set> oldCache = group2GroupCacheDAO.getCache(context); + + // correct cache, computed from the Group table + Set> newCache = computeNewCache(context, flushQueries); + + SetUtils.SetView> toDelete = SetUtils.difference(oldCache, newCache); + SetUtils.SetView> toCreate = SetUtils.difference(newCache, oldCache); + + for (Pair pair : toDelete ) { + group2GroupCacheDAO.deleteFromCache(context, pair.getLeft(), pair.getRight()); + } + + for (Pair pair : toCreate ) { + group2GroupCacheDAO.addToCache(context, pair.getLeft(), pair.getRight()); + } } @Override diff --git a/dspace-api/src/main/java/org/dspace/eperson/dao/Group2GroupCacheDAO.java b/dspace-api/src/main/java/org/dspace/eperson/dao/Group2GroupCacheDAO.java index 7db569a59e..d41d52c7e6 100644 --- a/dspace-api/src/main/java/org/dspace/eperson/dao/Group2GroupCacheDAO.java +++ b/dspace-api/src/main/java/org/dspace/eperson/dao/Group2GroupCacheDAO.java @@ -9,7 +9,10 @@ package org.dspace.eperson.dao; import java.sql.SQLException; import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.apache.commons.lang3.tuple.Pair; import org.dspace.core.Context; import org.dspace.core.GenericDAO; import org.dspace.eperson.Group; @@ -25,13 +28,74 @@ import org.dspace.eperson.Group2GroupCache; */ public interface Group2GroupCacheDAO extends GenericDAO { - public List findByParent(Context context, Group group) throws SQLException; + /** + * Returns the current cache table as a set of UUID pairs. + * @param context The relevant DSpace Context. + * @return Set of UUID pairs, where the first element is the parent UUID and the second one is the child UUID. + * @throws SQLException An exception that provides information on a database access error or other errors. + */ + Set> getCache(Context context) throws SQLException; - public List findByChildren(Context context, Iterable groups) throws SQLException; + /** + * Returns all cache entities that are children of a given parent Group entity. + * @param context The relevant DSpace Context. + * @param group Parent group to perform the search. + * @return List of cached groups that are children of the parent group. + * @throws SQLException An exception that provides information on a database access error or other errors. + */ + List findByParent(Context context, Group group) throws SQLException; - public Group2GroupCache findByParentAndChild(Context context, Group parent, Group child) throws SQLException; + /** + * Returns all cache entities that are parents of at least one group from a children groups list. + * @param context The relevant DSpace Context. + * @param groups Children groups to perform the search. + * @return List of cached groups that are parents of at least one group from the children groups list. + * @throws SQLException An exception that provides information on a database access error or other errors. + */ + List findByChildren(Context context, Iterable groups) throws SQLException; - public Group2GroupCache find(Context context, Group parent, Group child) throws SQLException; + /** + * Returns the cache entity given specific parent and child groups. + * @param context The relevant DSpace Context. + * @param parent Parent group. + * @param child Child gruoup. + * @return Cached group. + * @throws SQLException An exception that provides information on a database access error or other errors. + */ + Group2GroupCache findByParentAndChild(Context context, Group parent, Group child) throws SQLException; - public void deleteAll(Context context) throws SQLException; + /** + * Returns the cache entity given specific parent and child groups. + * @param context The relevant DSpace Context. + * @param parent Parent group. + * @param child Child gruoup. + * @return Cached group. + * @throws SQLException An exception that provides information on a database access error or other errors. + */ + Group2GroupCache find(Context context, Group parent, Group child) throws SQLException; + + /** + * Completely deletes the current cache table. + * @param context The relevant DSpace Context. + * @throws SQLException An exception that provides information on a database access error or other errors. + */ + void deleteAll(Context context) throws SQLException; + + /** + * Deletes a specific cache row given parent and child groups UUIDs. + * @param context The relevant DSpace Context. + * @param parent Parent group UUID. + * @param child Child group UUID. + * @throws SQLException An exception that provides information on a database access error or other errors. + */ + void deleteFromCache(Context context, UUID parent, UUID child) throws SQLException; + + /** + * Adds a single row to the cache table given parent and child groups UUIDs. + * @param context The relevant DSpace Context. + * @param parent Parent group UUID. + * @param child Child group UUID. + * @throws SQLException An exception that provides information on a database access error or other errors. + */ + void addToCache(Context context, UUID parent, UUID child) throws SQLException; } diff --git a/dspace-api/src/main/java/org/dspace/eperson/dao/impl/Group2GroupCacheDAOImpl.java b/dspace-api/src/main/java/org/dspace/eperson/dao/impl/Group2GroupCacheDAOImpl.java index 1cd359188c..adbd776ffa 100644 --- a/dspace-api/src/main/java/org/dspace/eperson/dao/impl/Group2GroupCacheDAOImpl.java +++ b/dspace-api/src/main/java/org/dspace/eperson/dao/impl/Group2GroupCacheDAOImpl.java @@ -8,14 +8,18 @@ package org.dspace.eperson.dao.impl; import java.sql.SQLException; +import java.util.HashSet; import java.util.LinkedList; import java.util.List; +import java.util.Set; +import java.util.UUID; import jakarta.persistence.Query; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; +import org.apache.commons.lang3.tuple.Pair; import org.dspace.core.AbstractHibernateDAO; import org.dspace.core.Context; import org.dspace.eperson.Group; @@ -35,6 +39,16 @@ public class Group2GroupCacheDAOImpl extends AbstractHibernateDAO> getCache(Context context) throws SQLException { + Query query = createQuery( + context, + "SELECT new org.apache.commons.lang3.tuple.ImmutablePair(g.parent.id, g.child.id) FROM Group2GroupCache g" + ); + List> results = query.getResultList(); + return new HashSet>(results); + } + @Override public List findByParent(Context context, Group group) throws SQLException { CriteriaBuilder criteriaBuilder = getCriteriaBuilder(context); @@ -90,4 +104,24 @@ public class Group2GroupCacheDAOImpl extends AbstractHibernateDAO scopes = new HashSet(); - scopes.add(AnalyticsScopes.ANALYTICS); - scopes.add(AnalyticsScopes.ANALYTICS_EDIT); - scopes.add(AnalyticsScopes.ANALYTICS_MANAGE_USERS); - scopes.add(AnalyticsScopes.ANALYTICS_PROVISION); - scopes.add(AnalyticsScopes.ANALYTICS_READONLY); - - credential = new GoogleCredential.Builder() - .setTransport(httpTransport) - .setJsonFactory(jsonFactory) - .setServiceAccountId(emailAddress) - .setServiceAccountScopes(scopes) - .setServiceAccountPrivateKeyFromP12File(new File(certificateLocation)) - .build(); - - return credential; - } - - - public String getApplicationName() { - return applicationName; - } - - public String getTableId() { - return tableId; - } - - public String getEmailAddress() { - return emailAddress; - } - - public String getCertificateLocation() { - return certificateLocation; - } - - public JsonFactory getJsonFactory() { - return jsonFactory; - } - - public HttpTransport getHttpTransport() { - return httpTransport; - } - - public Credential getCredential() { - return credential; - } - - public Analytics getClient() { - return client; - } - -} - diff --git a/dspace-api/src/main/java/org/dspace/google/GoogleQueryManager.java b/dspace-api/src/main/java/org/dspace/google/GoogleQueryManager.java deleted file mode 100644 index 2719aef04d..0000000000 --- a/dspace-api/src/main/java/org/dspace/google/GoogleQueryManager.java +++ /dev/null @@ -1,49 +0,0 @@ -/** - * The contents of this file are subject to the license and copyright - * detailed in the LICENSE and NOTICE files at the root of the source - * tree and available online at - * - * http://www.dspace.org/license/ - */ - -package org.dspace.google; - -import java.io.IOException; - -import com.google.api.services.analytics.model.GaData; - - -/** - * User: Robin Taylor - * Date: 20/08/2014 - * Time: 09:26 - */ -public class GoogleQueryManager { - - public GaData getPageViews(String startDate, String endDate, String handle) throws IOException { - return GoogleAccount.getInstance().getClient().data().ga().get( - GoogleAccount.getInstance().getTableId(), - startDate, - endDate, - "ga:pageviews") // Metrics. - .setDimensions("ga:year,ga:month") - .setSort("-ga:year,-ga:month") - .setFilters("ga:pagePath=~/handle/" + handle + "$") - .execute(); - } - - public GaData getBitstreamDownloads(String startDate, String endDate, String handle) throws IOException { - return GoogleAccount.getInstance().getClient().data().ga().get( - GoogleAccount.getInstance().getTableId(), - startDate, - endDate, - "ga:totalEvents") // Metrics. - .setDimensions("ga:year,ga:month") - .setSort("-ga:year,-ga:month") - .setFilters( - "ga:eventCategory==bitstream;ga:eventAction==download;ga:pagePath=~" + handle + "/") - .execute(); - } - -} - diff --git a/dspace-api/src/main/java/org/dspace/google/GoogleRecorderEventListener.java b/dspace-api/src/main/java/org/dspace/google/GoogleRecorderEventListener.java deleted file mode 100644 index fb4e9c04de..0000000000 --- a/dspace-api/src/main/java/org/dspace/google/GoogleRecorderEventListener.java +++ /dev/null @@ -1,201 +0,0 @@ -/** - * The contents of this file are subject to the license and copyright - * detailed in the LICENSE and NOTICE files at the root of the source - * tree and available online at - * - * http://www.dspace.org/license/ - */ - -package org.dspace.google; - -import java.io.IOException; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -import jakarta.servlet.http.HttpServletRequest; -import org.apache.commons.lang3.StringUtils; -import org.apache.http.NameValuePair; -import org.apache.http.client.entity.UrlEncodedFormEntity; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClients; -import org.apache.http.message.BasicNameValuePair; -import org.apache.logging.log4j.Logger; -import org.dspace.content.factory.ContentServiceFactory; -import org.dspace.core.Constants; -import org.dspace.service.ClientInfoService; -import org.dspace.services.ConfigurationService; -import org.dspace.services.model.Event; -import org.dspace.usage.AbstractUsageEventListener; -import org.dspace.usage.UsageEvent; -import org.springframework.beans.factory.annotation.Autowired; - - -/** - * User: Robin Taylor - * Date: 14/08/2014 - * Time: 10:05 - * - * Notify Google Analytics of... well anything we want really. - * @deprecated Use org.dspace.google.GoogleAsyncEventListener instead - */ -@Deprecated -public class GoogleRecorderEventListener extends AbstractUsageEventListener { - - private String analyticsKey; - private CloseableHttpClient httpclient; - private String GoogleURL = "https://www.google-analytics.com/collect"; - private static Logger log = org.apache.logging.log4j.LogManager.getLogger(GoogleRecorderEventListener.class); - - protected ContentServiceFactory contentServiceFactory; - protected ConfigurationService configurationService; - protected ClientInfoService clientInfoService; - - public GoogleRecorderEventListener() { - // httpclient is threadsafe so we only need one. - httpclient = HttpClients.createDefault(); - } - - @Autowired - public void setContentServiceFactory(ContentServiceFactory contentServiceFactory) { - this.contentServiceFactory = contentServiceFactory; - } - - @Autowired - public void setConfigurationService(ConfigurationService configurationService) { - this.configurationService = configurationService; - } - - @Autowired - public void setClientInfoService(ClientInfoService clientInfoService) { - this.clientInfoService = clientInfoService; - } - - @Override - public void receiveEvent(Event event) { - if ((event instanceof UsageEvent)) { - log.debug("Usage event received " + event.getName()); - - // This is a wee bit messy but these keys should be combined in future. - analyticsKey = configurationService.getProperty("google.analytics.key"); - - if (StringUtils.isNotBlank(analyticsKey)) { - try { - UsageEvent ue = (UsageEvent) event; - - if (ue.getAction() == UsageEvent.Action.VIEW) { - if (ue.getObject().getType() == Constants.BITSTREAM) { - logEvent(ue, "bitstream", "download"); - - // Note: I've left this commented out code here to show how we could record page views - // as events, - // but since they are already taken care of by the Google Analytics Javascript there is - // not much point. - - //} else if (ue.getObject().getType() == Constants.ITEM) { - // logEvent(ue, "item", "view"); - //} else if (ue.getObject().getType() == Constants.COLLECTION) { - // logEvent(ue, "collection", "view"); - //} else if (ue.getObject().getType() == Constants.COMMUNITY) { - // logEvent(ue, "community", "view"); - } - } - } catch (Exception e) { - log.error(e.getMessage()); - } - } - } - } - - private void logEvent(UsageEvent ue, String category, String action) throws IOException, SQLException { - HttpPost httpPost = new HttpPost(GoogleURL); - - List nvps = new ArrayList(); - nvps.add(new BasicNameValuePair("v", "1")); - nvps.add(new BasicNameValuePair("tid", analyticsKey)); - - // Client Id, should uniquely identify the user or device. If we have a session id for the user - // then lets use it, else generate a UUID. - if (ue.getRequest().getSession(false) != null) { - nvps.add(new BasicNameValuePair("cid", ue.getRequest().getSession().getId())); - } else { - nvps.add(new BasicNameValuePair("cid", UUID.randomUUID().toString())); - } - - nvps.add(new BasicNameValuePair("t", "event")); - nvps.add(new BasicNameValuePair("uip", getIPAddress(ue.getRequest()))); - nvps.add(new BasicNameValuePair("ua", ue.getRequest().getHeader("USER-AGENT"))); - nvps.add(new BasicNameValuePair("dr", ue.getRequest().getHeader("referer"))); - nvps.add(new BasicNameValuePair("dp", ue.getRequest().getRequestURI())); - nvps.add(new BasicNameValuePair("dt", getObjectName(ue))); - nvps.add(new BasicNameValuePair("ec", category)); - nvps.add(new BasicNameValuePair("ea", action)); - - if (ue.getObject().getType() == Constants.BITSTREAM) { - // Bitstream downloads may occasionally be for collection or community images, so we need to label them - // with the parent object type. - nvps.add(new BasicNameValuePair("el", getParentType(ue))); - } - - httpPost.setEntity(new UrlEncodedFormEntity(nvps)); - - try (CloseableHttpResponse response2 = httpclient.execute(httpPost)) { - // I can't find a list of what are acceptable responses, so I log the response but take no action. - log.debug("Google Analytics response is " + response2.getStatusLine()); - } - - log.debug("Posted to Google Analytics - " + ue.getRequest().getRequestURI()); - } - - private String getParentType(UsageEvent ue) { - try { - int parentType = contentServiceFactory.getDSpaceObjectService(ue.getObject()) - .getParentObject(ue.getContext(), ue.getObject()).getType(); - if (parentType == Constants.ITEM) { - return "item"; - } else if (parentType == Constants.COLLECTION) { - return "collection"; - } else if (parentType == Constants.COMMUNITY) { - return "community"; - } - } catch (SQLException e) { - // This shouldn't merit interrupting the user's transaction so log the error and continue. - log.error( - "Error in Google Analytics recording - can't determine ParentObjectType for bitstream " + ue.getObject() - .getID()); - e.printStackTrace(); - } - - return null; - } - - private String getObjectName(UsageEvent ue) { - try { - if (ue.getObject().getType() == Constants.BITSTREAM) { - // For a bitstream download we really want to know the title of the owning item rather than the - // bitstream name. - return contentServiceFactory.getDSpaceObjectService(ue.getObject()) - .getParentObject(ue.getContext(), ue.getObject()).getName(); - } else { - return ue.getObject().getName(); - } - } catch (SQLException e) { - // This shouldn't merit interrupting the user's transaction so log the error and continue. - log.error( - "Error in Google Analytics recording - can't determine ParentObjectName for bitstream " + ue.getObject() - .getID()); - e.printStackTrace(); - } - - return null; - - } - - private String getIPAddress(HttpServletRequest request) { - return clientInfoService.getClientIp(request); - } - -} diff --git a/dspace-api/src/main/java/org/dspace/identifier/IdentifierServiceImpl.java b/dspace-api/src/main/java/org/dspace/identifier/IdentifierServiceImpl.java index b98aea24fa..e6dcfcdda6 100644 --- a/dspace-api/src/main/java/org/dspace/identifier/IdentifierServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/identifier/IdentifierServiceImpl.java @@ -57,6 +57,11 @@ public class IdentifierServiceImpl implements IdentifierService { } } + @Override + public List getProviders() { + return this.providers; + } + /** * Reserves identifiers for the item * diff --git a/dspace-api/src/main/java/org/dspace/identifier/VersionedDOIIdentifierProvider.java b/dspace-api/src/main/java/org/dspace/identifier/VersionedDOIIdentifierProvider.java index b970c9f06c..b011307e5a 100644 --- a/dspace-api/src/main/java/org/dspace/identifier/VersionedDOIIdentifierProvider.java +++ b/dspace-api/src/main/java/org/dspace/identifier/VersionedDOIIdentifierProvider.java @@ -355,7 +355,10 @@ public class VersionedDOIIdentifierProvider extends DOIIdentifierProvider implem if (changed) { try { itemService.clearMetadata(c, item, MD_SCHEMA, DOI_ELEMENT, DOI_QUALIFIER, Item.ANY); - itemService.addMetadata(c, item, MD_SCHEMA, DOI_ELEMENT, DOI_QUALIFIER, null, newIdentifiers); + // Checks if Array newIdentifiers is empty to avoid adding null values to the metadata field. + if (!newIdentifiers.isEmpty()) { + itemService.addMetadata(c, item, MD_SCHEMA, DOI_ELEMENT, DOI_QUALIFIER, null, newIdentifiers); + } itemService.update(c, item); } catch (SQLException ex) { throw new RuntimeException("A problem with the database connection occurred.", ex); diff --git a/dspace-api/src/main/java/org/dspace/identifier/doi/DOIOrganiser.java b/dspace-api/src/main/java/org/dspace/identifier/doi/DOIOrganiser.java index b03af68b42..25a83ec4a1 100644 --- a/dspace-api/src/main/java/org/dspace/identifier/doi/DOIOrganiser.java +++ b/dspace-api/src/main/java/org/dspace/identifier/doi/DOIOrganiser.java @@ -13,7 +13,6 @@ import java.io.PrintStream; import java.sql.SQLException; import java.util.Arrays; import java.util.Date; -import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.UUID; @@ -227,8 +226,16 @@ public class DOIOrganiser { } for (DOI doi : dois) { - organiser.reserve(doi); - context.uncacheEntity(doi); + doi = context.reloadEntity(doi); + try { + organiser.reserve(doi); + context.commit(); + } catch (RuntimeException e) { + System.err.format("DOI %s for object %s reservation failed, skipping: %s%n", + doi.getDSpaceObject().getID().toString(), + doi.getDoi(), e.getMessage()); + context.rollback(); + } } } catch (SQLException ex) { System.err.println("Error in database connection:" + ex.getMessage()); @@ -245,14 +252,22 @@ public class DOIOrganiser { + "that could be registered."); } for (DOI doi : dois) { - organiser.register(doi); - context.uncacheEntity(doi); + doi = context.reloadEntity(doi); + try { + organiser.register(doi); + context.commit(); + } catch (SQLException e) { + System.err.format("DOI %s for object %s registration failed, skipping: %s%n", + doi.getDSpaceObject().getID().toString(), + doi.getDoi(), e.getMessage()); + context.rollback(); + } } } catch (SQLException ex) { - System.err.println("Error in database connection:" + ex.getMessage()); + System.err.format("Error in database connection: %s%n", ex.getMessage()); ex.printStackTrace(System.err); - } catch (DOIIdentifierException ex) { - System.err.println("Error registering DOI identifier:" + ex.getMessage()); + } catch (RuntimeException ex) { + System.err.format("Error registering DOI identifier: %s%n", ex.getMessage()); } } @@ -268,8 +283,9 @@ public class DOIOrganiser { } for (DOI doi : dois) { + doi = context.reloadEntity(doi); organiser.update(doi); - context.uncacheEntity(doi); + context.commit(); } } catch (SQLException ex) { System.err.println("Error in database connection:" + ex.getMessage()); @@ -286,12 +302,17 @@ public class DOIOrganiser { + "that could be deleted."); } - Iterator iterator = dois.iterator(); - while (iterator.hasNext()) { - DOI doi = iterator.next(); - iterator.remove(); - organiser.delete(doi.getDoi()); - context.uncacheEntity(doi); + for (DOI doi : dois) { + doi = context.reloadEntity(doi); + try { + organiser.delete(doi.getDoi()); + context.commit(); + } catch (SQLException e) { + System.err.format("DOI %s for object %s deletion failed, skipping: %s%n", + doi.getDSpaceObject().getID().toString(), + doi.getDoi(), e.getMessage()); + context.rollback(); + } } } catch (SQLException ex) { System.err.println("Error in database connection:" + ex.getMessage()); @@ -401,12 +422,18 @@ public class DOIOrganiser { /** * Register DOI with the provider - * @param doiRow - doi to register - * @param filter - logical item filter to override - * @throws SQLException - * @throws DOIIdentifierException + * @param doiRow DOI to register + * @param filter logical item filter to override + * @throws IllegalArgumentException + * if {@link doiRow} does not name an Item. + * @throws IllegalStateException + * on invalid DOI. + * @throws RuntimeException + * on database error. */ - public void register(DOI doiRow, Filter filter) throws SQLException, DOIIdentifierException { + public void register(DOI doiRow, Filter filter) + throws IllegalArgumentException, IllegalStateException, + RuntimeException { DSpaceObject dso = doiRow.getDSpaceObject(); if (Constants.ITEM != dso.getType()) { throw new IllegalArgumentException("Currenty DSpace supports DOIs for Items only."); @@ -473,30 +500,33 @@ public class DOIOrganiser { } /** - * Register DOI with the provider - * @param doiRow - doi to register - * @throws SQLException - * @throws DOIIdentifierException + * Register DOI with the provider. + * @param doiRow DOI to register + * @throws IllegalArgumentException passed through. + * @throws IllegalStateException passed through. + * @throws RuntimeException passed through. */ - public void register(DOI doiRow) throws SQLException, DOIIdentifierException { + public void register(DOI doiRow) + throws IllegalStateException, IllegalArgumentException, + RuntimeException { register(doiRow, this.filter); } /** * Reserve DOI with the provider, * @param doiRow - doi to reserve - * @throws SQLException - * @throws DOIIdentifierException */ public void reserve(DOI doiRow) { reserve(doiRow, this.filter); } /** - * Reserve DOI with the provider + * Reserve DOI with the provider. * @param doiRow - doi to reserve - * @throws SQLException - * @throws DOIIdentifierException + * @param filter - Logical item filter to determine whether this + * identifier should be reserved online. + * @throws IllegalStateException on invalid DOI. + * @throws RuntimeException on database error. */ public void reserve(DOI doiRow, Filter filter) { DSpaceObject dso = doiRow.getDSpaceObject(); @@ -577,7 +607,8 @@ public class DOIOrganiser { } } catch (IdentifierException ex) { if (!(ex instanceof DOIIdentifierException)) { - LOG.error("It wasn't possible to register the identifier online. ", ex); + LOG.error("Registering DOI {} for object {}: the registrar returned an error.", + doiRow.getDoi(), dso.getID(), ex); } DOIIdentifierException doiIdentifierException = (DOIIdentifierException) ex; diff --git a/dspace-api/src/main/java/org/dspace/identifier/doi/DataCiteConnector.java b/dspace-api/src/main/java/org/dspace/identifier/doi/DataCiteConnector.java index 23af904f2c..e56a829423 100644 --- a/dspace-api/src/main/java/org/dspace/identifier/doi/DataCiteConnector.java +++ b/dspace-api/src/main/java/org/dspace/identifier/doi/DataCiteConnector.java @@ -461,6 +461,10 @@ public class DataCiteConnector log.warn("While reserving the DOI {}, we got a http status code " + "{} and the message \"{}\".", doi, Integer.toString(resp.statusCode), resp.getContent()); + Format format = Format.getCompactFormat(); + format.setEncoding("UTF-8"); + XMLOutputter xout = new XMLOutputter(format); + log.info("We send the following XML:\n{}", xout.outputString(root)); throw new DOIIdentifierException("Unable to parse an answer from " + "DataCite API. Please have a look into DSpace logs.", DOIIdentifierException.BAD_ANSWER); @@ -632,6 +636,14 @@ public class DataCiteConnector return sendHttpRequest(httpget, doi); } + /** + * Send a DataCite metadata document to the registrar. + * + * @param doi identify the object. + * @param metadataRoot describe the object. The root element of the document. + * @return the registrar's response. + * @throws DOIIdentifierException passed through. + */ protected DataCiteResponse sendMetadataPostRequest(String doi, Element metadataRoot) throws DOIIdentifierException { Format format = Format.getCompactFormat(); @@ -640,6 +652,14 @@ public class DataCiteConnector return sendMetadataPostRequest(doi, xout.outputString(new Document(metadataRoot))); } + /** + * Send a DataCite metadata document to the registrar. + * + * @param doi identify the object. + * @param metadata describe the object. + * @return the registrar's response. + * @throws DOIIdentifierException passed through. + */ protected DataCiteResponse sendMetadataPostRequest(String doi, String metadata) throws DOIIdentifierException { // post mds/metadata/ @@ -687,7 +707,7 @@ public class DataCiteConnector * properties such as request URI and method type. * @param doi DOI string to operate on * @return response from DataCite - * @throws DOIIdentifierException if DOI error + * @throws DOIIdentifierException if registrar returns an error. */ protected DataCiteResponse sendHttpRequest(HttpUriRequest req, String doi) throws DOIIdentifierException { diff --git a/dspace-api/src/main/java/org/dspace/identifier/doi/package-info.java b/dspace-api/src/main/java/org/dspace/identifier/doi/package-info.java index e92170daf0..30ee5c45dd 100644 --- a/dspace-api/src/main/java/org/dspace/identifier/doi/package-info.java +++ b/dspace-api/src/main/java/org/dspace/identifier/doi/package-info.java @@ -6,17 +6,14 @@ * http://www.dspace.org/license/ */ /** - * Make requests to the DOI registration angencies, f.e.to - * EZID DOI service, and analyze the responses. - * + * Make requests to the DOI registration agencies and analyze the responses. + * *

- * Use {@link org.dspace.identifier.ezid.EZIDRequestFactory#getInstance} to - * configure an {@link org.dspace.identifier.ezid.EZIDRequest} - * with your authority number and credentials. {@code EZIDRequest} encapsulates - * EZID's operations (lookup, create/mint, modify, delete...). - * An operation returns an {@link org.dspace.identifier.ezid.EZIDResponse} which - * gives easy access to EZID's status code and value, status of the underlying - * HTTP request, and key/value pairs found in the response body (if any). - *

+ * {@link DOIOrganiser} is a tool for managing DOI registrations. + * + *

+ * Classes specific to the DataCite + * registrar are here. See {@link org.dspace.identifier.ezid} for the + * EZID registrar. */ package org.dspace.identifier.doi; diff --git a/dspace-api/src/main/java/org/dspace/identifier/ezid/package-info.java b/dspace-api/src/main/java/org/dspace/identifier/ezid/package-info.java new file mode 100644 index 0000000000..bff0bdea26 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/identifier/ezid/package-info.java @@ -0,0 +1,21 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +/** + * DOI classes specific to the EZID registrar. + * + *

+ * Use {@link org.dspace.identifier.ezid.EZIDRequestFactory#getInstance} to + * configure an {@link org.dspace.identifier.ezid.EZIDRequest} + * with your authority number and credentials. {@code EZIDRequest} encapsulates + * EZID's operations (lookup, create/mint, modify, delete...). + * An operation returns an {@link org.dspace.identifier.ezid.EZIDResponse} which + * gives easy access to EZID's status code and value, status of the underlying + * HTTP request, and key/value pairs found in the response body (if any). + *

+ */ +package org.dspace.identifier.ezid; diff --git a/dspace-api/src/main/java/org/dspace/identifier/service/IdentifierService.java b/dspace-api/src/main/java/org/dspace/identifier/service/IdentifierService.java index 23005b6575..45bf3c6dea 100644 --- a/dspace-api/src/main/java/org/dspace/identifier/service/IdentifierService.java +++ b/dspace-api/src/main/java/org/dspace/identifier/service/IdentifierService.java @@ -19,6 +19,7 @@ import org.dspace.identifier.Identifier; import org.dspace.identifier.IdentifierException; import org.dspace.identifier.IdentifierNotFoundException; import org.dspace.identifier.IdentifierNotResolvableException; +import org.dspace.identifier.IdentifierProvider; /** * @author Fabio Bolognesi (fabio at atmire dot com) @@ -194,4 +195,9 @@ public interface IdentifierService { void delete(Context context, DSpaceObject dso, String identifier) throws AuthorizeException, SQLException, IdentifierException; + /** + * Get List of currently enabled IdentifierProviders + * @return List of enabled IdentifierProvider objects. + */ + List getProviders(); } diff --git a/dspace-api/src/main/java/org/dspace/importer/external/datacite/DataCiteImportMetadataSourceServiceImpl.java b/dspace-api/src/main/java/org/dspace/importer/external/datacite/DataCiteImportMetadataSourceServiceImpl.java index e00b2e2cea..a8caaba092 100644 --- a/dspace-api/src/main/java/org/dspace/importer/external/datacite/DataCiteImportMetadataSourceServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/importer/external/datacite/DataCiteImportMetadataSourceServiceImpl.java @@ -53,6 +53,16 @@ public class DataCiteImportMetadataSourceServiceImpl @Autowired private ConfigurationService configurationService; + private String entityFilterQuery; + + public String getEntityFilterQuery() { + return entityFilterQuery; + } + + public void setEntityFilterQuery(String entityFilterQuery) { + this.entityFilterQuery = entityFilterQuery; + } + @Override public String getImportSource() { return "datacite"; @@ -80,6 +90,9 @@ public class DataCiteImportMetadataSourceServiceImpl if (StringUtils.isBlank(id)) { id = query; } + if (StringUtils.isNotBlank(getEntityFilterQuery())) { + id = id + " " + getEntityFilterQuery(); + } uriParameters.put("query", id); uriParameters.put("page[size]", "1"); int timeoutMs = configurationService.getIntProperty("datacite.timeout", 180000); @@ -118,6 +131,9 @@ public class DataCiteImportMetadataSourceServiceImpl if (StringUtils.isBlank(id)) { id = query; } + if (StringUtils.isNotBlank(getEntityFilterQuery())) { + id = id + " " + getEntityFilterQuery(); + } uriParameters.put("query", id); // start = current dspace page / datacite page number starting with 1 // dspace rounds up/down to the next configured pagination settings. diff --git a/dspace-api/src/main/java/org/dspace/importer/external/datacite/DataCiteProjectFieldMapping.java b/dspace-api/src/main/java/org/dspace/importer/external/datacite/DataCiteProjectFieldMapping.java new file mode 100644 index 0000000000..c0c0539a5e --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/importer/external/datacite/DataCiteProjectFieldMapping.java @@ -0,0 +1,38 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.importer.external.datacite; + +import java.util.Map; + +import jakarta.annotation.Resource; +import org.dspace.importer.external.metadatamapping.AbstractMetadataFieldMapping; + +/** + * An implementation of {@link AbstractMetadataFieldMapping} + * Responsible for defining the mapping of the datacite metadatum fields on the DSpace metadatum fields + * + * @author Pasquale Cavallo (pasquale.cavallo at 4science dot it) + * @author Florian Gantner (florian.gantner@uni-bamberg.de) + */ +public class DataCiteProjectFieldMapping extends AbstractMetadataFieldMapping { + + /** + * Defines which metadatum is mapped on which metadatum. Note that while the key must be unique it + * only matters here for postprocessing of the value. The mapped MetadatumContributor has full control over + * what metadatafield is generated. + * + * @param metadataFieldMap The map containing the link between retrieve metadata and metadata that will be set to + * the item. + */ + @Override + @Resource(name = "dataciteProjectMetadataFieldMap") + public void setMetadataFieldMap(Map metadataFieldMap) { + super.setMetadataFieldMap(metadataFieldMap); + } + +} diff --git a/dspace-api/src/main/java/org/dspace/importer/external/datacite/DataCiteProjectImportMetadataSourceServiceImpl.java b/dspace-api/src/main/java/org/dspace/importer/external/datacite/DataCiteProjectImportMetadataSourceServiceImpl.java new file mode 100644 index 0000000000..b598f15683 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/importer/external/datacite/DataCiteProjectImportMetadataSourceServiceImpl.java @@ -0,0 +1,24 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.importer.external.datacite; + +/** + * Implements a data source for querying Datacite for specific for Project resourceTypes. + * This inherits the methods of DataCiteImportMetadataSourceServiceImpl + * + * @author Florian Gantner (florian.gantner@uni-bamberg.de) + * + */ +public class DataCiteProjectImportMetadataSourceServiceImpl + extends DataCiteImportMetadataSourceServiceImpl { + + @Override + public String getImportSource() { + return "dataciteProject"; + } +} diff --git a/dspace-api/src/main/java/org/dspace/importer/external/metadatamapping/contributor/SimpleConcatContributor.java b/dspace-api/src/main/java/org/dspace/importer/external/metadatamapping/contributor/SimpleConcatContributor.java index d84bc65701..9a2aa242c6 100644 --- a/dspace-api/src/main/java/org/dspace/importer/external/metadatamapping/contributor/SimpleConcatContributor.java +++ b/dspace-api/src/main/java/org/dspace/importer/external/metadatamapping/contributor/SimpleConcatContributor.java @@ -26,7 +26,7 @@ import org.jdom2.xpath.XPathFactory; * This contributor is able to concat multi value. * Given a certain path, if it contains several nodes, * the values of nodes will be concatenated into a single one. - * The concrete example we can see in the file wos-responce.xml in the node, + * The concrete example we can see in the file wos-response.xml in the node, * which may contain several

paragraphs, * this Contributor allows concatenating all

paragraphs. to obtain a single one. * diff --git a/dspace-api/src/main/java/org/dspace/importer/external/metadatamapping/transform/StringJsonValueMappingMetadataProcessorService.java b/dspace-api/src/main/java/org/dspace/importer/external/metadatamapping/transform/StringJsonValueMappingMetadataProcessorService.java new file mode 100644 index 0000000000..1a5e50e4ac --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/importer/external/metadatamapping/transform/StringJsonValueMappingMetadataProcessorService.java @@ -0,0 +1,86 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.importer.external.metadatamapping.transform; + +import static java.util.Optional.ofNullable; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Optional; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeType; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.dspace.importer.external.metadatamapping.contributor.JsonPathMetadataProcessor; +import org.dspace.util.SimpleMapConverter; + +/** + * This class is a Metadata processor from a structured JSON Metadata result + * and uses a SimpleMapConverter, with a mapping properties file + * to map to a single string value based on mapped keys.
+ * Like:
+ * journal-article = Article + * + * @author paulo-graca + * + */ +public class StringJsonValueMappingMetadataProcessorService implements JsonPathMetadataProcessor { + + private final static Logger log = LogManager.getLogger(); + /** + * The value map converter. + * a list of values to map from + */ + private SimpleMapConverter valueMapConverter; + private String path; + + @Override + public Collection processMetadata(String json) { + JsonNode rootNode = convertStringJsonToJsonNode(json); + Optional abstractNode = Optional.of(rootNode.at(path)); + Collection values = new ArrayList<>(); + + if (abstractNode.isPresent() && abstractNode.get().getNodeType().equals(JsonNodeType.STRING)) { + + String stringValue = abstractNode.get().asText(); + values.add(ofNullable(stringValue) + .map(value -> valueMapConverter != null ? valueMapConverter.getValue(value) : value) + .orElse(valueMapConverter.getValue(null))); + } + return values; + } + + private JsonNode convertStringJsonToJsonNode(String json) { + ObjectMapper mapper = new ObjectMapper(); + JsonNode body = null; + try { + body = mapper.readTree(json); + } catch (JsonProcessingException e) { + log.error("Unable to process json response.", e); + } + return body; + } + + /* Getters and Setters */ + + public String convertType(String type) { + return valueMapConverter != null ? valueMapConverter.getValue(type) : type; + } + + public void setValueMapConverter(SimpleMapConverter valueMapConverter) { + this.valueMapConverter = valueMapConverter; + } + + public void setPath(String path) { + this.path = path; + } + +} diff --git a/dspace-api/src/main/java/org/dspace/orcid/client/OrcidClient.java b/dspace-api/src/main/java/org/dspace/orcid/client/OrcidClient.java index 99d1920aa5..d21f61a922 100644 --- a/dspace-api/src/main/java/org/dspace/orcid/client/OrcidClient.java +++ b/dspace-api/src/main/java/org/dspace/orcid/client/OrcidClient.java @@ -10,6 +10,7 @@ package org.dspace.orcid.client; import java.util.List; import java.util.Optional; +import org.dspace.orcid.OrcidToken; import org.dspace.orcid.exception.OrcidClientException; import org.dspace.orcid.model.OrcidTokenResponseDTO; import org.orcid.jaxb.model.v3.release.record.Person; @@ -161,4 +162,11 @@ public interface OrcidClient { */ OrcidResponse deleteByPutCode(String accessToken, String orcid, String putCode, String path); + /** + * Revokes the given {@param accessToken} with a POST method. + * @param orcidToken the access token to revoke + * @throws OrcidClientException if some error occurs during the search + */ + void revokeToken(OrcidToken orcidToken); + } diff --git a/dspace-api/src/main/java/org/dspace/orcid/client/OrcidClientImpl.java b/dspace-api/src/main/java/org/dspace/orcid/client/OrcidClientImpl.java index 2dc1d591fd..d691b625ee 100644 --- a/dspace-api/src/main/java/org/dspace/orcid/client/OrcidClientImpl.java +++ b/dspace-api/src/main/java/org/dspace/orcid/client/OrcidClientImpl.java @@ -42,6 +42,7 @@ import org.apache.http.client.methods.RequestBuilder; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.message.BasicNameValuePair; +import org.dspace.orcid.OrcidToken; import org.dspace.orcid.exception.OrcidClientException; import org.dspace.orcid.model.OrcidEntityType; import org.dspace.orcid.model.OrcidProfileSectionType; @@ -178,6 +179,16 @@ public class OrcidClientImpl implements OrcidClient { return execute(buildDeleteUriRequest(accessToken, "/" + orcid + path + "/" + putCode), true); } + @Override + public void revokeToken(OrcidToken orcidToken) { + List params = new ArrayList<>(); + params.add(new BasicNameValuePair("client_id", orcidConfiguration.getClientId())); + params.add(new BasicNameValuePair("client_secret", orcidConfiguration.getClientSecret())); + params.add(new BasicNameValuePair("token", orcidToken.getAccessToken())); + + executeSuccessful(buildPostForRevokeToken(new UrlEncodedFormEntity(params, Charset.defaultCharset()))); + } + @Override public OrcidTokenResponseDTO getReadPublicAccessToken() { return getClientCredentialsAccessToken("/read-public"); @@ -220,6 +231,14 @@ public class OrcidClientImpl implements OrcidClient { .build(); } + private HttpUriRequest buildPostForRevokeToken(HttpEntity entity) { + return post(orcidConfiguration.getRevokeUrl()) + .addHeader("Accept", "application/json") + .addHeader("Content-Type", "application/x-www-form-urlencoded") + .setEntity(entity) + .build(); + } + private HttpUriRequest buildPutUriRequest(String accessToken, String relativePath, Object object) { return put(orcidConfiguration.getApiUrl() + relativePath.trim()) .addHeader("Content-Type", "application/vnd.orcid+xml") @@ -234,6 +253,24 @@ public class OrcidClientImpl implements OrcidClient { .build(); } + private void executeSuccessful(HttpUriRequest httpUriRequest) { + try { + HttpClient client = HttpClientBuilder.create().build(); + HttpResponse response = client.execute(httpUriRequest); + + if (isNotSuccessfull(response)) { + throw new OrcidClientException( + getStatusCode(response), + "Operation " + httpUriRequest.getMethod() + " for the resource " + httpUriRequest.getURI() + + " was not successful: " + new String(response.getEntity().getContent().readAllBytes(), + StandardCharsets.UTF_8) + ); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + private T executeAndParseJson(HttpUriRequest httpUriRequest, Class clazz) { HttpClient client = HttpClientBuilder.create().build(); diff --git a/dspace-api/src/main/java/org/dspace/orcid/client/OrcidConfiguration.java b/dspace-api/src/main/java/org/dspace/orcid/client/OrcidConfiguration.java index 550b0215c4..dfa90fcae0 100644 --- a/dspace-api/src/main/java/org/dspace/orcid/client/OrcidConfiguration.java +++ b/dspace-api/src/main/java/org/dspace/orcid/client/OrcidConfiguration.java @@ -35,6 +35,8 @@ public final class OrcidConfiguration { private String scopes; + private String revokeUrl; + public String getApiUrl() { return apiUrl; } @@ -111,4 +113,11 @@ public final class OrcidConfiguration { return !StringUtils.isAnyBlank(clientId, clientSecret); } + public String getRevokeUrl() { + return revokeUrl; + } + + public void setRevokeUrl(String revokeUrl) { + this.revokeUrl = revokeUrl; + } } diff --git a/dspace-api/src/main/java/org/dspace/orcid/consumer/OrcidQueueConsumer.java b/dspace-api/src/main/java/org/dspace/orcid/consumer/OrcidQueueConsumer.java index 97da341fb8..6b174e9695 100644 --- a/dspace-api/src/main/java/org/dspace/orcid/consumer/OrcidQueueConsumer.java +++ b/dspace-api/src/main/java/org/dspace/orcid/consumer/OrcidQueueConsumer.java @@ -14,9 +14,10 @@ import static java.util.Comparator.nullsFirst; import static org.apache.commons.collections.CollectionUtils.isNotEmpty; import java.sql.SQLException; -import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -82,7 +83,7 @@ public class OrcidQueueConsumer implements Consumer { private RelationshipService relationshipService; - private final List alreadyConsumedItems = new ArrayList<>(); + private final Set itemsToConsume = new HashSet<>(); @Override public void initialize() throws Exception { @@ -117,17 +118,26 @@ public class OrcidQueueConsumer implements Consumer { return; } - if (alreadyConsumedItems.contains(item.getID())) { - return; - } - - context.turnOffAuthorisationSystem(); - try { - consumeItem(context, item); - } finally { - context.restoreAuthSystemState(); + itemsToConsume.add(item.getID()); + } + + @Override + public void end(Context context) throws Exception { + + for (UUID itemId : itemsToConsume) { + + Item item = itemService.find(context, itemId); + + context.turnOffAuthorisationSystem(); + try { + consumeItem(context, item); + } finally { + context.restoreAuthSystemState(); + } + } + itemsToConsume.clear(); } /** @@ -146,7 +156,7 @@ public class OrcidQueueConsumer implements Consumer { consumeProfile(context, item); } - alreadyConsumedItems.add(item.getID()); + itemsToConsume.add(item.getID()); } @@ -169,6 +179,10 @@ public class OrcidQueueConsumer implements Consumer { continue; } + if (isNotLatestVersion(context, entity)) { + continue; + } + orcidQueueService.create(context, relatedItem, entity); } @@ -329,6 +343,14 @@ public class OrcidQueueConsumer implements Consumer { return !getProfileType().equals(itemService.getEntityTypeLabel(profileItemItem)); } + private boolean isNotLatestVersion(Context context, Item entity) { + try { + return !itemService.isLatestVersion(context, entity); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + private String getMetadataValue(Item item, String metadataField) { return itemService.getMetadataFirstValue(item, new MetadataFieldName(metadataField), Item.ANY); } @@ -345,11 +367,6 @@ public class OrcidQueueConsumer implements Consumer { return !configurationService.getBooleanProperty("orcid.synchronization-enabled", true); } - @Override - public void end(Context context) throws Exception { - alreadyConsumedItems.clear(); - } - @Override public void finish(Context context) throws Exception { // nothing to do diff --git a/dspace-api/src/main/java/org/dspace/orcid/dao/OrcidQueueDAO.java b/dspace-api/src/main/java/org/dspace/orcid/dao/OrcidQueueDAO.java index 235443b150..b7e0b1ed2a 100644 --- a/dspace-api/src/main/java/org/dspace/orcid/dao/OrcidQueueDAO.java +++ b/dspace-api/src/main/java/org/dspace/orcid/dao/OrcidQueueDAO.java @@ -74,6 +74,16 @@ public interface OrcidQueueDAO extends GenericDAO { */ public List findByProfileItemOrEntity(Context context, Item item) throws SQLException; + /** + * Get the OrcidQueue records where the given item is the entity. + * + * @param context DSpace context object + * @param item the item to search for + * @return the found OrcidQueue entities + * @throws SQLException if database error + */ + public List findByEntity(Context context, Item item) throws SQLException; + /** * Find all the OrcidQueue records with the given entity and record type. * diff --git a/dspace-api/src/main/java/org/dspace/orcid/dao/impl/OrcidQueueDAOImpl.java b/dspace-api/src/main/java/org/dspace/orcid/dao/impl/OrcidQueueDAOImpl.java index c8e48e3f17..091e597505 100644 --- a/dspace-api/src/main/java/org/dspace/orcid/dao/impl/OrcidQueueDAOImpl.java +++ b/dspace-api/src/main/java/org/dspace/orcid/dao/impl/OrcidQueueDAOImpl.java @@ -63,6 +63,13 @@ public class OrcidQueueDAOImpl extends AbstractHibernateDAO implemen return query.getResultList(); } + @Override + public List findByEntity(Context context, Item item) throws SQLException { + Query query = createQuery(context, "FROM OrcidQueue WHERE entity.id = :itemId"); + query.setParameter("itemId", item.getID()); + return query.getResultList(); + } + @Override public List findByEntityAndRecordType(Context context, Item entity, String type) throws SQLException { Query query = createQuery(context, "FROM OrcidQueue WHERE entity = :entity AND recordType = :type"); diff --git a/dspace-api/src/main/java/org/dspace/orcid/service/OrcidQueueService.java b/dspace-api/src/main/java/org/dspace/orcid/service/OrcidQueueService.java index 8de25e9caf..b667088eab 100644 --- a/dspace-api/src/main/java/org/dspace/orcid/service/OrcidQueueService.java +++ b/dspace-api/src/main/java/org/dspace/orcid/service/OrcidQueueService.java @@ -164,6 +164,16 @@ public interface OrcidQueueService { */ public List findByProfileItemOrEntity(Context context, Item item) throws SQLException; + /** + * Get the OrcidQueue records where the given item is the entity. + * + * @param context DSpace context object + * @param item the item to search for + * @return the found OrcidQueue records + * @throws SQLException if database error + */ + public List findByEntity(Context context, Item item) throws SQLException; + /** * Get all the OrcidQueue records with attempts less than the given attempts. * diff --git a/dspace-api/src/main/java/org/dspace/orcid/service/impl/OrcidQueueServiceImpl.java b/dspace-api/src/main/java/org/dspace/orcid/service/impl/OrcidQueueServiceImpl.java index d3300fea66..261f8ef9a9 100644 --- a/dspace-api/src/main/java/org/dspace/orcid/service/impl/OrcidQueueServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/orcid/service/impl/OrcidQueueServiceImpl.java @@ -70,6 +70,11 @@ public class OrcidQueueServiceImpl implements OrcidQueueService { return orcidQueueDAO.findByProfileItemOrEntity(context, item); } + @Override + public List findByEntity(Context context, Item item) throws SQLException { + return orcidQueueDAO.findByEntity(context, item); + } + @Override public long countByProfileItemId(Context context, UUID profileItemId) throws SQLException { return orcidQueueDAO.countByProfileItemId(context, profileItemId); diff --git a/dspace-api/src/main/java/org/dspace/orcid/service/impl/OrcidSynchronizationServiceImpl.java b/dspace-api/src/main/java/org/dspace/orcid/service/impl/OrcidSynchronizationServiceImpl.java index dacb41aba7..554abc5404 100644 --- a/dspace-api/src/main/java/org/dspace/orcid/service/impl/OrcidSynchronizationServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/orcid/service/impl/OrcidSynchronizationServiceImpl.java @@ -38,6 +38,7 @@ import org.dspace.eperson.EPerson; import org.dspace.eperson.service.EPersonService; import org.dspace.orcid.OrcidQueue; import org.dspace.orcid.OrcidToken; +import org.dspace.orcid.client.OrcidClient; import org.dspace.orcid.model.OrcidEntityType; import org.dspace.orcid.model.OrcidTokenResponseDTO; import org.dspace.orcid.service.OrcidQueueService; @@ -49,6 +50,8 @@ import org.dspace.profile.OrcidProfileSyncPreference; import org.dspace.profile.OrcidSynchronizationMode; import org.dspace.profile.service.ResearcherProfileService; import org.dspace.services.ConfigurationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; /** @@ -59,6 +62,8 @@ import org.springframework.beans.factory.annotation.Autowired; */ public class OrcidSynchronizationServiceImpl implements OrcidSynchronizationService { + private static final Logger log = LoggerFactory.getLogger(OrcidSynchronizationServiceImpl.class); + @Autowired private ItemService itemService; @@ -80,6 +85,9 @@ public class OrcidSynchronizationServiceImpl implements OrcidSynchronizationServ @Autowired private ResearcherProfileService researcherProfileService; + @Autowired + private OrcidClient orcidClient; + @Override public void linkProfile(Context context, Item profile, OrcidTokenResponseDTO token) throws SQLException { @@ -118,24 +126,11 @@ public class OrcidSynchronizationServiceImpl implements OrcidSynchronizationServ @Override public void unlinkProfile(Context context, Item profile) throws SQLException { + clearOrcidProfileMetadata(context, profile); - String orcid = itemService.getMetadataFirstValue(profile, "person", "identifier", "orcid", Item.ANY); + clearSynchronizationSettings(context, profile); - itemService.clearMetadata(context, profile, "person", "identifier", "orcid", Item.ANY); - itemService.clearMetadata(context, profile, "dspace", "orcid", "scope", Item.ANY); - itemService.clearMetadata(context, profile, "dspace", "orcid", "authenticated", Item.ANY); - - if (!configurationService.getBooleanProperty("orcid.disconnection.remain-sync", false)) { - clearSynchronizationSettings(context, profile); - } - - EPerson eperson = ePersonService.findByNetid(context, orcid); - if (eperson != null ) { - eperson.setNetid(null); - updateEPerson(context, eperson); - } - - orcidTokenService.deleteByProfileItem(context, profile); + clearOrcidToken(context, profile); updateItem(context, profile); @@ -146,6 +141,23 @@ public class OrcidSynchronizationServiceImpl implements OrcidSynchronizationServ } + private void clearOrcidToken(Context context, Item profile) { + OrcidToken profileToken = orcidTokenService.findByProfileItem(context, profile); + if (profileToken == null) { + log.warn("Cannot find any token related to the user profile: {}", profile.getID()); + return; + } + + orcidTokenService.deleteByProfileItem(context, profile); + orcidClient.revokeToken(profileToken); + } + + private void clearOrcidProfileMetadata(Context context, Item profile) throws SQLException { + itemService.clearMetadata(context, profile, "person", "identifier", "orcid", Item.ANY); + itemService.clearMetadata(context, profile, "dspace", "orcid", "scope", Item.ANY); + itemService.clearMetadata(context, profile, "dspace", "orcid", "authenticated", Item.ANY); + } + @Override public boolean setEntityPreference(Context context, Item profile, OrcidEntityType type, OrcidEntitySyncPreference value) throws SQLException { @@ -291,6 +303,11 @@ public class OrcidSynchronizationServiceImpl implements OrcidSynchronizationServ private void clearSynchronizationSettings(Context context, Item profile) throws SQLException { + + if (configurationService.getBooleanProperty("orcid.disconnection.remain-sync", false)) { + return; + } + itemService.clearMetadata(context, profile, "dspace", "orcid", "sync-mode", Item.ANY); itemService.clearMetadata(context, profile, "dspace", "orcid", "sync-profile", Item.ANY); diff --git a/dspace-api/src/main/java/org/dspace/rdf/RDFConsumer.java b/dspace-api/src/main/java/org/dspace/rdf/RDFConsumer.java index 8b43ad69d7..0101472c21 100644 --- a/dspace-api/src/main/java/org/dspace/rdf/RDFConsumer.java +++ b/dspace-api/src/main/java/org/dspace/rdf/RDFConsumer.java @@ -243,7 +243,7 @@ public class RDFConsumer implements Consumer { DSOIdentifier id = new DSOIdentifier(dso, ctx); // If an item gets withdrawn, a MODIFY event is fired. We have to // delete the item from the triple store instead of converting it. - // we don't have to take care for reinstantions of items as they can + // we don't have to take care for reinstate events on items as they can // be processed as normal modify events. if (dso instanceof Item && event.getDetail() != null diff --git a/dspace-api/src/main/java/org/dspace/scripts/ProcessServiceImpl.java b/dspace-api/src/main/java/org/dspace/scripts/ProcessServiceImpl.java index f242ec7acd..b3893f7443 100644 --- a/dspace-api/src/main/java/org/dspace/scripts/ProcessServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/scripts/ProcessServiceImpl.java @@ -45,8 +45,8 @@ import org.dspace.core.Context; import org.dspace.core.LogHelper; import org.dspace.eperson.EPerson; import org.dspace.eperson.Group; -import org.dspace.eperson.service.EPersonService; import org.dspace.scripts.service.ProcessService; +import org.dspace.services.ConfigurationService; import org.springframework.beans.factory.annotation.Autowired; /** @@ -72,7 +72,7 @@ public class ProcessServiceImpl implements ProcessService { private MetadataFieldService metadataFieldService; @Autowired - private EPersonService ePersonService; + private ConfigurationService configurationService; @Override public Process create(Context context, EPerson ePerson, String scriptName, @@ -293,8 +293,8 @@ public class ProcessServiceImpl implements ProcessService { @Override public void appendLog(int processId, String scriptName, String output, ProcessLogLevel processLogLevel) throws IOException { - File tmpDir = FileUtils.getTempDirectory(); - File tempFile = new File(tmpDir, scriptName + processId + ".log"); + File logsDir = getLogsDirectory(); + File tempFile = new File(logsDir, processId + "-" + scriptName + ".log"); FileWriter out = new FileWriter(tempFile, true); try { try (BufferedWriter writer = new BufferedWriter(out)) { @@ -309,12 +309,15 @@ public class ProcessServiceImpl implements ProcessService { @Override public void createLogBitstream(Context context, Process process) throws IOException, SQLException, AuthorizeException { - File tmpDir = FileUtils.getTempDirectory(); - File tempFile = new File(tmpDir, process.getName() + process.getID() + ".log"); - FileInputStream inputStream = FileUtils.openInputStream(tempFile); - appendFile(context, process, inputStream, Process.OUTPUT_TYPE, process.getName() + process.getID() + ".log"); - inputStream.close(); - tempFile.delete(); + File logsDir = getLogsDirectory(); + File tempFile = new File(logsDir, process.getID() + "-" + process.getName() + ".log"); + if (tempFile.exists()) { + FileInputStream inputStream = FileUtils.openInputStream(tempFile); + appendFile(context, process, inputStream, Process.OUTPUT_TYPE, + process.getID() + "-" + process.getName() + ".log"); + inputStream.close(); + tempFile.delete(); + } } @Override @@ -328,6 +331,23 @@ public class ProcessServiceImpl implements ProcessService { return processDAO.countByUser(context, user); } + @Override + public void failRunningProcesses(Context context) throws SQLException, IOException, AuthorizeException { + List processesToBeFailed = findByStatusAndCreationTimeOlderThan( + context, List.of(ProcessStatus.RUNNING, ProcessStatus.SCHEDULED), new Date()); + for (Process process : processesToBeFailed) { + context.setCurrentUser(process.getEPerson()); + // Fail the process. + log.info("Process with ID {} did not complete before tomcat shutdown, failing it now.", process.getID()); + fail(context, process); + // But still attach its log to the process. + appendLog(process.getID(), process.getName(), + "Process did not complete before tomcat shutdown.", + ProcessLogLevel.ERROR); + createLogBitstream(context, process); + } + } + private String formatLogLine(int processId, String scriptName, String output, ProcessLogLevel processLogLevel) { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"); StringBuilder sb = new StringBuilder(); @@ -343,4 +363,15 @@ public class ProcessServiceImpl implements ProcessService { return sb.toString(); } + private File getLogsDirectory() { + String pathStr = configurationService.getProperty("dspace.dir") + + File.separator + "log" + File.separator + "processes"; + File logsDir = new File(pathStr); + if (!logsDir.exists()) { + if (!logsDir.mkdirs()) { + throw new RuntimeException("Couldn't create [dspace.dir]/log/processes/ directory."); + } + } + return logsDir; + } } diff --git a/dspace-api/src/main/java/org/dspace/scripts/service/ProcessService.java b/dspace-api/src/main/java/org/dspace/scripts/service/ProcessService.java index c6fc248881..5df2ca15aa 100644 --- a/dspace-api/src/main/java/org/dspace/scripts/service/ProcessService.java +++ b/dspace-api/src/main/java/org/dspace/scripts/service/ProcessService.java @@ -277,4 +277,14 @@ public interface ProcessService { * @throws SQLException If something goes wrong */ int countByUser(Context context, EPerson user) throws SQLException; + + /** + * Cleans up running processes by failing them an attaching their logs to the process objects. + * + * @param context The DSpace context + * @throws SQLException + * @throws IOException + * @throws AuthorizeException + */ + void failRunningProcesses(Context context) throws SQLException, IOException, AuthorizeException; } diff --git a/dspace-api/src/main/java/org/dspace/statistics/util/StatisticsImporter.java b/dspace-api/src/main/java/org/dspace/statistics/util/StatisticsImporter.java index 95736a8bd6..354c803fe2 100644 --- a/dspace-api/src/main/java/org/dspace/statistics/util/StatisticsImporter.java +++ b/dspace-api/src/main/java/org/dspace/statistics/util/StatisticsImporter.java @@ -357,7 +357,7 @@ public class StatisticsImporter { SolrInputDocument sid = new SolrInputDocument(); sid.addField("ip", ip); sid.addField("type", dso.getType()); - sid.addField("id", dso.getID()); + sid.addField("id", dso.getID().toString()); sid.addField("time", DateFormatUtils.format(date, SolrLoggerServiceImpl.DATE_FORMAT_8601)); sid.addField("continent", continent); sid.addField("country", country); @@ -471,13 +471,13 @@ public class StatisticsImporter { boolean verbose = line.hasOption('v'); // Find our solr server - String sserver = configurationService.getProperty("solr-statistics", "server"); + String sserver = configurationService.getProperty("solr-statistics.server"); if (verbose) { System.out.println("Writing to solr server at: " + sserver); } solr = new HttpSolrClient.Builder(sserver).build(); - String dbPath = configurationService.getProperty("usage-statistics", "dbfile"); + String dbPath = configurationService.getProperty("usage-statistics.dbfile"); try { File dbFile = new File(dbPath); geoipLookup = new DatabaseReader.Builder(dbFile).build(); @@ -492,6 +492,11 @@ public class StatisticsImporter { "Unable to load GeoLite Database file (" + dbPath + ")! You may need to reinstall it. See the DSpace " + "installation instructions for more details.", e); + } catch (NullPointerException e) { + log.error( + "The value of the property usage-statistics.dbfile is null. You may need to install the GeoLite " + + "Database file and/or uncomment the property in the config file!", + e); } diff --git a/dspace-api/src/main/java/org/dspace/usage/package-info.java b/dspace-api/src/main/java/org/dspace/usage/package-info.java index 5883bcf358..26984ae0ca 100644 --- a/dspace-api/src/main/java/org/dspace/usage/package-info.java +++ b/dspace-api/src/main/java/org/dspace/usage/package-info.java @@ -25,7 +25,7 @@ * {@code EventService}, as with the stock listeners. *

* - * @see org.dspace.google.GoogleRecorderEventListener + * @see org.dspace.google.GoogleAsyncEventListener * @see org.dspace.statistics.SolrLoggerUsageEventListener */ diff --git a/dspace-api/src/main/java/org/dspace/versioning/VersioningConsumer.java b/dspace-api/src/main/java/org/dspace/versioning/VersioningConsumer.java index 63b5391d0a..27a81a1579 100644 --- a/dspace-api/src/main/java/org/dspace/versioning/VersioningConsumer.java +++ b/dspace-api/src/main/java/org/dspace/versioning/VersioningConsumer.java @@ -33,6 +33,11 @@ import org.dspace.core.Context; import org.dspace.discovery.IndexEventConsumer; import org.dspace.event.Consumer; import org.dspace.event.Event; +import org.dspace.orcid.OrcidHistory; +import org.dspace.orcid.OrcidQueue; +import org.dspace.orcid.factory.OrcidServiceFactory; +import org.dspace.orcid.service.OrcidHistoryService; +import org.dspace.orcid.service.OrcidQueueService; import org.dspace.versioning.factory.VersionServiceFactory; import org.dspace.versioning.service.VersionHistoryService; import org.dspace.versioning.utils.RelationshipVersioningUtils; @@ -58,6 +63,8 @@ public class VersioningConsumer implements Consumer { private RelationshipTypeService relationshipTypeService; private RelationshipService relationshipService; private RelationshipVersioningUtils relationshipVersioningUtils; + private OrcidQueueService orcidQueueService; + private OrcidHistoryService orcidHistoryService; @Override public void initialize() throws Exception { @@ -67,6 +74,8 @@ public class VersioningConsumer implements Consumer { relationshipTypeService = ContentServiceFactory.getInstance().getRelationshipTypeService(); relationshipService = ContentServiceFactory.getInstance().getRelationshipService(); relationshipVersioningUtils = VersionServiceFactory.getInstance().getRelationshipVersioningUtils(); + this.orcidQueueService = OrcidServiceFactory.getInstance().getOrcidQueueService(); + this.orcidHistoryService = OrcidServiceFactory.getInstance().getOrcidHistoryService(); } @Override @@ -132,7 +141,8 @@ public class VersioningConsumer implements Consumer { // unarchive previous item unarchiveItem(ctx, previousItem); - + // handles versions for ORCID publications waiting to be shipped, or already published (history-queue). + handleOrcidSynchronization(ctx, previousItem, latestItem); // update relationships updateRelationships(ctx, latestItem, previousItem); } @@ -148,6 +158,29 @@ public class VersioningConsumer implements Consumer { )); } + private void handleOrcidSynchronization(Context ctx, Item previousItem, Item latestItem) { + try { + replaceOrcidHistoryEntities(ctx, previousItem, latestItem); + removeOrcidQueueEntries(ctx, previousItem); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + private void removeOrcidQueueEntries(Context ctx, Item previousItem) throws SQLException { + List queueEntries = orcidQueueService.findByEntity(ctx, previousItem); + for (OrcidQueue queueEntry : queueEntries) { + orcidQueueService.delete(ctx, queueEntry); + } + } + + private void replaceOrcidHistoryEntities(Context ctx, Item previousItem, Item latestItem) throws SQLException { + List entries = orcidHistoryService.findByEntity(ctx, previousItem); + for (OrcidHistory entry : entries) { + entry.setEntity(latestItem); + } + } + /** * Update {@link Relationship#latestVersionStatus} of the relationships of both the old version and the new version * of the item. diff --git a/dspace-api/src/main/java/org/dspace/xmlworkflow/state/actions/processingaction/ScoreReviewAction.java b/dspace-api/src/main/java/org/dspace/xmlworkflow/state/actions/processingaction/ScoreReviewAction.java index a8ed4fd3da..50f3384992 100644 --- a/dspace-api/src/main/java/org/dspace/xmlworkflow/state/actions/processingaction/ScoreReviewAction.java +++ b/dspace-api/src/main/java/org/dspace/xmlworkflow/state/actions/processingaction/ScoreReviewAction.java @@ -8,6 +8,7 @@ package org.dspace.xmlworkflow.state.actions.processingaction; import java.sql.SQLException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -20,6 +21,8 @@ import org.dspace.app.util.Util; import org.dspace.authorize.AuthorizeException; import org.dspace.content.MetadataFieldName; import org.dspace.core.Context; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; import org.dspace.xmlworkflow.service.WorkflowRequirementsService; import org.dspace.xmlworkflow.state.Step; import org.dspace.xmlworkflow.state.actions.ActionAdvancedInfo; @@ -34,6 +37,9 @@ import org.dspace.xmlworkflow.storedcomponents.XmlWorkflowItem; public class ScoreReviewAction extends ProcessingAction { private static final Logger log = LogManager.getLogger(ScoreReviewAction.class); + private final ConfigurationService configurationService + = DSpaceServicesFactory.getInstance().getConfigurationService(); + // Option(s) public static final String SUBMIT_SCORE = "submit_score"; @@ -114,7 +120,14 @@ public class ScoreReviewAction extends ProcessingAction { @Override public List getOptions() { - return List.of(SUBMIT_SCORE, RETURN_TO_POOL); + List options = new ArrayList<>(); + options.add(SUBMIT_SCORE); + if (configurationService.getBooleanProperty("workflow.reviewer.file-edit", false)) { + options.add(SUBMIT_EDIT_METADATA); + } + options.add(RETURN_TO_POOL); + + return options; } @Override diff --git a/dspace-api/src/main/java/org/dspace/xmlworkflow/state/actions/processingaction/SingleUserReviewAction.java b/dspace-api/src/main/java/org/dspace/xmlworkflow/state/actions/processingaction/SingleUserReviewAction.java index 64e0957b65..c46fa851e4 100644 --- a/dspace-api/src/main/java/org/dspace/xmlworkflow/state/actions/processingaction/SingleUserReviewAction.java +++ b/dspace-api/src/main/java/org/dspace/xmlworkflow/state/actions/processingaction/SingleUserReviewAction.java @@ -21,6 +21,8 @@ import org.dspace.content.WorkspaceItem; import org.dspace.content.factory.ContentServiceFactory; import org.dspace.core.Context; import org.dspace.eperson.EPerson; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; import org.dspace.workflow.WorkflowException; import org.dspace.xmlworkflow.factory.XmlWorkflowServiceFactory; import org.dspace.xmlworkflow.state.Step; @@ -40,6 +42,9 @@ import org.dspace.xmlworkflow.storedcomponents.XmlWorkflowItem; public class SingleUserReviewAction extends ProcessingAction { private static final Logger log = LogManager.getLogger(SingleUserReviewAction.class); + private final ConfigurationService configurationService + = DSpaceServicesFactory.getInstance().getConfigurationService(); + public static final int OUTCOME_REJECT = 1; protected static final String SUBMIT_DECLINE_TASK = "submit_decline_task"; @@ -95,6 +100,9 @@ public class SingleUserReviewAction extends ProcessingAction { public List getOptions() { List options = new ArrayList<>(); options.add(SUBMIT_APPROVE); + if (configurationService.getBooleanProperty("workflow.reviewer.file-edit", false)) { + options.add(SUBMIT_EDIT_METADATA); + } options.add(SUBMIT_REJECT); options.add(SUBMIT_DECLINE_TASK); return options; diff --git a/dspace-api/src/main/java/org/dspace/xmlworkflow/storedcomponents/PoolTaskServiceImpl.java b/dspace-api/src/main/java/org/dspace/xmlworkflow/storedcomponents/PoolTaskServiceImpl.java index fb673725e1..d3c8f6334d 100644 --- a/dspace-api/src/main/java/org/dspace/xmlworkflow/storedcomponents/PoolTaskServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/xmlworkflow/storedcomponents/PoolTaskServiceImpl.java @@ -13,6 +13,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; import java.util.List; +import java.util.Optional; import java.util.Set; import org.apache.commons.collections4.CollectionUtils; @@ -100,12 +101,17 @@ public class PoolTaskServiceImpl implements PoolTaskService { //If the user does not have a claimedtask yet, see whether one of the groups of the user has pooltasks //for this workflow item Set groups = groupService.allMemberGroupsSet(context, ePerson); - for (Group group : groups) { - poolTask = poolTaskDAO.findByWorkflowItemAndGroup(context, group, workflowItem); - if (poolTask != null) { - return poolTask; - } + List generalTasks = poolTaskDAO.findByWorkflowItem(context, workflowItem); + Optional firstClaimedTask = groups.stream() + .flatMap(group -> generalTasks.stream() + .filter(f -> f.getGroup().getID().equals(group.getID())) + .findFirst() + .stream()) + .findFirst(); + + if (firstClaimedTask.isPresent()) { + return firstClaimedTask.get(); } } } diff --git a/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/h2/V7.6_2024.12.17__workspaceitem_add_item_id_unique_constraint.sql b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/h2/V7.6_2024.12.17__workspaceitem_add_item_id_unique_constraint.sql new file mode 100644 index 0000000000..38389bf2d1 --- /dev/null +++ b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/h2/V7.6_2024.12.17__workspaceitem_add_item_id_unique_constraint.sql @@ -0,0 +1,21 @@ +-- +-- The contents of this file are subject to the license and copyright +-- detailed in the LICENSE and NOTICE files at the root of the source +-- tree and available online at +-- +-- http://www.dspace.org/license/ +-- + +-- In the workspaceitem table, if there are multiple rows referring to the same item ID, keep only the first of them. +DELETE FROM workspaceitem WHERE EXISTS ( + SELECT item_id + FROM workspaceitem + GROUP BY item_id + HAVING COUNT(workspace_item_id) > 1 +) AND workspaceitem.workspace_item_id NOT IN ( + SELECT MIN(workspace_item_id) AS workspace_item_id + FROM workspaceitem + GROUP BY item_id +); +-- Identify which rows have duplicates, and compute their replacements. +ALTER TABLE workspaceitem ADD CONSTRAINT unique_item_id UNIQUE(item_id); diff --git a/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/postgres/V7.6_2024.12.17__workspaceitem_add_item_id_unique_constraint.sql b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/postgres/V7.6_2024.12.17__workspaceitem_add_item_id_unique_constraint.sql new file mode 100644 index 0000000000..20eb0f9119 --- /dev/null +++ b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/postgres/V7.6_2024.12.17__workspaceitem_add_item_id_unique_constraint.sql @@ -0,0 +1,21 @@ +-- +-- The contents of this file are subject to the license and copyright +-- detailed in the LICENSE and NOTICE files at the root of the source +-- tree and available online at +-- +-- http://www.dspace.org/license/ +-- + +-- In the workspaceitem table, if there are multiple rows referring to the same item ID, keep only the first of them. +WITH dedup AS ( + SELECT item_id, MIN(workspace_item_id) AS workspace_item_id + FROM workspaceitem + GROUP BY item_id + HAVING COUNT(workspace_item_id) > 1 +) +DELETE FROM workspaceitem +USING dedup +WHERE workspaceitem.item_id = dedup.item_id AND workspaceitem.workspace_item_id <> dedup.workspace_item_id; + +-- Enforce uniqueness of item_id in workspaceitem table. +ALTER TABLE workspaceitem ADD CONSTRAINT unique_item_id UNIQUE(item_id); diff --git a/dspace-api/src/main/resources/spring/spring-dspace-addon-import-services.xml b/dspace-api/src/main/resources/spring/spring-dspace-addon-import-services.xml index f7943fb232..a4b7e2e457 100644 --- a/dspace-api/src/main/resources/spring/spring-dspace-addon-import-services.xml +++ b/dspace-api/src/main/resources/spring/spring-dspace-addon-import-services.xml @@ -51,11 +51,21 @@ + + + + + + + + diff --git a/dspace-api/src/test/data/dspaceFolder/config/controlled-vocabularies/countries.xml b/dspace-api/src/test/data/dspaceFolder/config/controlled-vocabularies/countries.xml new file mode 100644 index 0000000000..078e8bfa38 --- /dev/null +++ b/dspace-api/src/test/data/dspaceFolder/config/controlled-vocabularies/countries.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/dspace-api/src/test/data/dspaceFolder/config/controlled-vocabularies/countries_de.xml b/dspace-api/src/test/data/dspaceFolder/config/controlled-vocabularies/countries_de.xml new file mode 100644 index 0000000000..b4bbf0b1e0 --- /dev/null +++ b/dspace-api/src/test/data/dspaceFolder/config/controlled-vocabularies/countries_de.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/dspace-api/src/test/data/dspaceFolder/config/local.cfg b/dspace-api/src/test/data/dspaceFolder/config/local.cfg index b44f319a35..1aaacd4e24 100644 --- a/dspace-api/src/test/data/dspaceFolder/config/local.cfg +++ b/dspace-api/src/test/data/dspaceFolder/config/local.cfg @@ -156,8 +156,6 @@ useProxies = true proxies.trusted.ipranges = 7.7.7.7 proxies.trusted.include_ui_ip = true -csvexport.dir = dspace-server-webapp/src/test/data/dspaceFolder/exports - # For the tests we have to disable this health indicator because there isn't a mock server and the calculated status was DOWN management.health.solrOai.enabled = false @@ -175,6 +173,9 @@ authority.controlled.dspace.object.owner = true webui.browse.link.1 = author:dc.contributor.* webui.browse.link.2 = subject:dc.subject.* +# Configuration required for testing the controlled vocabulary functionality, which is configured using properties +vocabulary.plugin.countries.hierarchy.store=false +vocabulary.plugin.countries.storeIDs=true # Enable duplicate detection for tests duplicate.enable = true diff --git a/dspace-api/src/test/data/dspaceFolder/config/spring/api/external-services.xml b/dspace-api/src/test/data/dspaceFolder/config/spring/api/external-services.xml index 83d45b38cc..5450ad73aa 100644 --- a/dspace-api/src/test/data/dspaceFolder/config/spring/api/external-services.xml +++ b/dspace-api/src/test/data/dspaceFolder/config/spring/api/external-services.xml @@ -104,5 +104,16 @@ + + + + + + + + Project + + + diff --git a/dspace-api/src/test/java/org/dspace/AbstractDSpaceIntegrationTest.java b/dspace-api/src/test/java/org/dspace/AbstractDSpaceIntegrationTest.java index 821e22dcef..516f13bfd8 100644 --- a/dspace-api/src/test/java/org/dspace/AbstractDSpaceIntegrationTest.java +++ b/dspace-api/src/test/java/org/dspace/AbstractDSpaceIntegrationTest.java @@ -21,8 +21,12 @@ import org.dspace.builder.AbstractBuilder; import org.dspace.discovery.SearchUtils; import org.dspace.servicemanager.DSpaceKernelImpl; import org.dspace.servicemanager.DSpaceKernelInit; +import org.junit.After; import org.junit.AfterClass; +import org.junit.Before; import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.rules.TestName; /** * Abstract Test class copied from DSpace API @@ -46,6 +50,12 @@ public class AbstractDSpaceIntegrationTest { */ protected static DSpaceKernelImpl kernelImpl; + /** + * Obtain the TestName from JUnit, so that we can print it out in the test logs (see below) + */ + @Rule + public TestName testName = new TestName(); + /** * Default constructor */ @@ -90,6 +100,20 @@ public class AbstractDSpaceIntegrationTest { } } + @Before + public void printTestMethodBefore() { + // Log the test method being executed. Put lines around it to make it stand out. + log.info("---"); + log.info("Starting execution of test method: {}()", testName.getMethodName()); + log.info("---"); + } + + @After + public void printTestMethodAfter() { + // Log the test method just completed. + log.info("Finished execution of test method: {}()", testName.getMethodName()); + } + /** * This method will be run after all tests finish as per @AfterClass. It * will clean resources initialized by the @BeforeClass methods. diff --git a/dspace-api/src/test/java/org/dspace/AbstractDSpaceTest.java b/dspace-api/src/test/java/org/dspace/AbstractDSpaceTest.java index 36477556d3..136af83f07 100644 --- a/dspace-api/src/test/java/org/dspace/AbstractDSpaceTest.java +++ b/dspace-api/src/test/java/org/dspace/AbstractDSpaceTest.java @@ -18,9 +18,13 @@ import java.util.TimeZone; import org.apache.logging.log4j.Logger; import org.dspace.servicemanager.DSpaceKernelImpl; import org.dspace.servicemanager.DSpaceKernelInit; +import org.junit.After; import org.junit.AfterClass; +import org.junit.Before; import org.junit.BeforeClass; import org.junit.Ignore; +import org.junit.Rule; +import org.junit.rules.TestName; import org.junit.runner.RunWith; import org.mockito.junit.MockitoJUnitRunner; @@ -62,6 +66,12 @@ public class AbstractDSpaceTest { */ protected static DSpaceKernelImpl kernelImpl; + /** + * Obtain the TestName from JUnit, so that we can print it out in the test logs (see below) + */ + @Rule + public TestName testName = new TestName(); + /** * This method will be run before the first test as per @BeforeClass. It will * initialize shared resources required for all tests of this class. @@ -94,6 +104,19 @@ public class AbstractDSpaceTest { } } + @Before + public void printTestMethodBefore() { + // Log the test method being executed. Put lines around it to make it stand out. + log.info("---"); + log.info("Starting execution of test method: {}()", testName.getMethodName()); + log.info("---"); + } + + @After + public void printTestMethodAfter() { + // Log the test method just completed. + log.info("Finished execution of test method: {}()", testName.getMethodName()); + } /** * This method will be run after all tests finish as per @AfterClass. It diff --git a/dspace-api/src/test/java/org/dspace/AbstractIntegrationTestWithDatabase.java b/dspace-api/src/test/java/org/dspace/AbstractIntegrationTestWithDatabase.java index 9bacbb97ee..76b3fe131b 100644 --- a/dspace-api/src/test/java/org/dspace/AbstractIntegrationTestWithDatabase.java +++ b/dspace-api/src/test/java/org/dspace/AbstractIntegrationTestWithDatabase.java @@ -20,8 +20,8 @@ import org.dspace.app.launcher.ScriptLauncher; import org.dspace.app.scripts.handler.impl.TestDSpaceRunnableHandler; import org.dspace.authority.AuthoritySearchService; import org.dspace.authority.MockAuthoritySolrServiceImpl; -import org.dspace.authorize.AuthorizeException; import org.dspace.builder.AbstractBuilder; +import org.dspace.builder.EPersonBuilder; import org.dspace.content.Community; import org.dspace.core.Context; import org.dspace.core.I18nUtil; @@ -127,19 +127,16 @@ public class AbstractIntegrationTestWithDatabase extends AbstractDSpaceIntegrati EPersonService ePersonService = EPersonServiceFactory.getInstance().getEPersonService(); eperson = ePersonService.findByEmail(context, "test@email.com"); if (eperson == null) { - // This EPerson creation should only happen once (i.e. for first test run) - log.info("Creating initial EPerson (email=test@email.com) for Unit Tests"); - eperson = ePersonService.create(context); - eperson.setFirstName(context, "first"); - eperson.setLastName(context, "last"); - eperson.setEmail("test@email.com"); - eperson.setCanLogIn(true); - eperson.setLanguage(context, I18nUtil.getDefaultLocale().getLanguage()); - ePersonService.setPassword(eperson, password); - // actually save the eperson to unit testing DB - ePersonService.update(context, eperson); + // Create test EPerson for usage in all tests + log.info("Creating Test EPerson (email=test@email.com) for Integration Tests"); + eperson = EPersonBuilder.createEPerson(context) + .withNameInMetadata("first", "last") + .withEmail("test@email.com") + .withCanLogin(true) + .withLanguage(I18nUtil.getDefaultLocale().getLanguage()) + .withPassword(password) + .build(); } - // Set our global test EPerson as the current user in DSpace context.setCurrentUser(eperson); @@ -148,26 +145,23 @@ public class AbstractIntegrationTestWithDatabase extends AbstractDSpaceIntegrati admin = ePersonService.findByEmail(context, "admin@email.com"); if (admin == null) { - // This EPerson creation should only happen once (i.e. for first test run) - log.info("Creating initial EPerson (email=admin@email.com) for Unit Tests"); - admin = ePersonService.create(context); - admin.setFirstName(context, "first (admin)"); - admin.setLastName(context, "last (admin)"); - admin.setEmail("admin@email.com"); - admin.setCanLogIn(true); - admin.setLanguage(context, I18nUtil.getDefaultLocale().getLanguage()); - ePersonService.setPassword(admin, password); - // actually save the eperson to unit testing DB - ePersonService.update(context, admin); + // Create test Administrator for usage in all tests + log.info("Creating Test Admin EPerson (email=admin@email.com) for Integration Tests"); + admin = EPersonBuilder.createEPerson(context) + .withNameInMetadata("first (admin)", "last (admin)") + .withEmail("admin@email.com") + .withCanLogin(true) + .withLanguage(I18nUtil.getDefaultLocale().getLanguage()) + .withPassword(password) + .build(); + + // Add Test Administrator to the ADMIN group in test database GroupService groupService = EPersonServiceFactory.getInstance().getGroupService(); Group adminGroup = groupService.findByName(context, Group.ADMIN); groupService.addMember(context, adminGroup, admin); } context.restoreAuthSystemState(); - } catch (AuthorizeException ex) { - log.error("Error creating initial eperson or default groups", ex); - fail("Error creating initial eperson or default groups in AbstractUnitTest init()"); } catch (SQLException ex) { log.error(ex.getMessage(), ex); fail("SQL Error on AbstractUnitTest init()"); diff --git a/dspace-api/src/test/java/org/dspace/app/bulkedit/MetadataExportSearchIT.java b/dspace-api/src/test/java/org/dspace/app/bulkedit/MetadataExportSearchIT.java index 3a972692ef..63a87a48f5 100644 --- a/dspace-api/src/test/java/org/dspace/app/bulkedit/MetadataExportSearchIT.java +++ b/dspace-api/src/test/java/org/dspace/app/bulkedit/MetadataExportSearchIT.java @@ -23,7 +23,8 @@ import java.util.List; import com.google.common.io.Files; import com.opencsv.CSVReader; import com.opencsv.exceptions.CsvException; -import org.apache.log4j.Logger; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.dspace.AbstractIntegrationTestWithDatabase; import org.dspace.app.launcher.ScriptLauncher; import org.dspace.app.scripts.handler.impl.TestDSpaceRunnableHandler; @@ -51,7 +52,7 @@ public class MetadataExportSearchIT extends AbstractIntegrationTestWithDatabase private Item[] itemsSubject2 = new Item[numberItemsSubject2]; private String filename; private Collection collection; - private Logger logger = Logger.getLogger(MetadataExportSearchIT.class); + private Logger logger = LogManager.getLogger(MetadataExportSearchIT.class); private ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); private SearchService searchService; diff --git a/dspace-api/src/test/java/org/dspace/authenticate/SamlAuthenticationTest.java b/dspace-api/src/test/java/org/dspace/authenticate/SamlAuthenticationTest.java new file mode 100644 index 0000000000..e3a08901c5 --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/authenticate/SamlAuthenticationTest.java @@ -0,0 +1,511 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.authenticate; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.List; + +import jakarta.servlet.http.HttpServletRequest; +import org.dspace.AbstractUnitTest; +import org.dspace.builder.AbstractBuilder; +import org.dspace.builder.EPersonBuilder; +import org.dspace.content.MetadataValue; +import org.dspace.eperson.EPerson; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +public class SamlAuthenticationTest extends AbstractUnitTest { + private static ConfigurationService configurationService; + + private HttpServletRequest request; + private SamlAuthentication samlAuth; + private EPerson testUser; + + @BeforeClass + public static void beforeAll() { + configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + + AbstractBuilder.init(); // AbstractUnitTest doesn't do this for us. + } + + @Before + public void beforeEach() throws Exception { + configurationService.setProperty("authentication-saml.autoregister", true); + configurationService.setProperty("authentication-saml.eperson.metadata.autocreate", true); + + request = new MockHttpServletRequest(); + samlAuth = new SamlAuthentication(); + testUser = null; + } + + @After + public void afterEach() throws Exception { + if (testUser != null) { + EPersonBuilder.deleteEPerson(testUser.getID()); + } + } + + @AfterClass + public static void afterAll() { + AbstractBuilder.destroy(); // AbstractUnitTest doesn't do this for us. + } + + @Test + public void testAuthenticateExistingUserByEmail() throws Exception { + context.setCurrentUser(null); + context.turnOffAuthorisationSystem(); + + testUser = EPersonBuilder.createEPerson(context) + .withEmail("alyssa@dspace.org") + .withNameInMetadata("Alyssa", "Hacker") + .withCanLogin(true) + .build(); + + context.restoreAuthSystemState(); + + request.setAttribute("org.dspace.saml.EMAIL", List.of("alyssa@dspace.org")); + + int result = samlAuth.authenticate(context, null, null, null, request); + + assertEquals(AuthenticationMethod.SUCCESS, result); + + EPerson user = context.getCurrentUser(); + + assertNotNull(user); + assertEquals("alyssa@dspace.org", user.getEmail()); + assertNull(user.getNetid()); + assertEquals("Alyssa", user.getFirstName()); + assertEquals("Hacker", user.getLastName()); + } + + @Test + public void testAuthenticateExistingUserByNetId() throws Exception { + context.setCurrentUser(null); + context.turnOffAuthorisationSystem(); + + testUser = EPersonBuilder.createEPerson(context) + .withEmail("alyssa@dspace.org") + .withNetId("001") + .withNameInMetadata("Alyssa", "Hacker") + .withCanLogin(true) + .build(); + + context.restoreAuthSystemState(); + + request.setAttribute("org.dspace.saml.NAME_ID", "001"); + + int result = samlAuth.authenticate(context, null, null, null, request); + + assertEquals(AuthenticationMethod.SUCCESS, result); + + EPerson user = context.getCurrentUser(); + + assertNotNull(user); + assertEquals("alyssa@dspace.org", user.getEmail()); + assertEquals("001", user.getNetid()); + assertEquals("Alyssa", user.getFirstName()); + assertEquals("Hacker", user.getLastName()); + } + + @Test + public void testAuthenticateExistingUserByEmailWithUnexpectedNetId() throws Exception { + EPerson originalUser = context.getCurrentUser(); + + context.turnOffAuthorisationSystem(); + + testUser = EPersonBuilder.createEPerson(context) + .withEmail("ben@dspace.org") + .withNetId("002") + .withNameInMetadata("Ben", "Bitdiddle") + .withCanLogin(true) + .build(); + + context.restoreAuthSystemState(); + + request.setAttribute("org.dspace.saml.EMAIL", List.of("ben@dspace.org")); + request.setAttribute("org.dspace.saml.NAME_ID", "oh-no-its-different-than-the-stored-netid"); + + int result = samlAuth.authenticate(context, null, null, null, request); + + assertEquals(AuthenticationMethod.NO_SUCH_USER, result); + assertEquals(originalUser, context.getCurrentUser()); + } + + @Test + public void testAuthenticateExistingUserByEmailUpdatesNullNetId() throws Exception { + context.setCurrentUser(null); + context.turnOffAuthorisationSystem(); + + testUser = EPersonBuilder.createEPerson(context) + .withEmail("carrie@dspace.org") + .withNameInMetadata("Carrie", "Pragma") + .withCanLogin(true) + .build(); + + context.restoreAuthSystemState(); + + request.setAttribute("org.dspace.saml.EMAIL", List.of("carrie@dspace.org")); + request.setAttribute("org.dspace.saml.NAME_ID", "netid-from-idp"); + + int result = samlAuth.authenticate(context, null, null, null, request); + + assertEquals(AuthenticationMethod.SUCCESS, result); + + EPerson user = context.getCurrentUser(); + + assertNotNull(user); + assertEquals("carrie@dspace.org", user.getEmail()); + assertEquals("netid-from-idp", user.getNetid()); + assertEquals("Carrie", user.getFirstName()); + assertEquals("Pragma", user.getLastName()); + } + + @Test + public void testAuthenticateExistingUserByNetIdUpdatesEmail() throws Exception { + context.setCurrentUser(null); + context.turnOffAuthorisationSystem(); + + testUser = EPersonBuilder.createEPerson(context) + .withEmail("alyssa@dspace.org") + .withNetId("001") + .withNameInMetadata("Alyssa", "Hacker") + .withCanLogin(true) + .build(); + + context.restoreAuthSystemState(); + + request.setAttribute("org.dspace.saml.NAME_ID", "001"); + request.setAttribute("org.dspace.saml.EMAIL", List.of("aphacker@dspace.org")); + + int result = samlAuth.authenticate(context, null, null, null, request); + + assertEquals(AuthenticationMethod.SUCCESS, result); + + EPerson user = context.getCurrentUser(); + + assertNotNull(user); + assertEquals("aphacker@dspace.org", user.getEmail()); + assertEquals("001", user.getNetid()); + assertEquals("Alyssa", user.getFirstName()); + assertEquals("Hacker", user.getLastName()); + } + + @Test + public void testAuthenticateExistingUserUpdatesName() throws Exception { + context.setCurrentUser(null); + context.turnOffAuthorisationSystem(); + + testUser = EPersonBuilder.createEPerson(context) + .withEmail("alyssa@dspace.org") + .withNetId("001") + .withNameInMetadata("Alyssa", "Hacker") + .withCanLogin(true) + .build(); + + context.restoreAuthSystemState(); + + request.setAttribute("org.dspace.saml.NAME_ID", "001"); + request.setAttribute("org.dspace.saml.GIVEN_NAME", "Liz"); + request.setAttribute("org.dspace.saml.SURNAME", "Hacker-Bitdiddle"); + + int result = samlAuth.authenticate(context, null, null, null, request); + + assertEquals(AuthenticationMethod.SUCCESS, result); + + EPerson user = context.getCurrentUser(); + + assertNotNull(user); + assertEquals("alyssa@dspace.org", user.getEmail()); + assertEquals("001", user.getNetid()); + assertEquals("Liz", user.getFirstName()); + assertEquals("Hacker-Bitdiddle", user.getLastName()); + } + + @Test + public void testAuthenticateExistingUserAdditionalMetadata() throws Exception { + configurationService.setProperty("authentication-saml.eperson.metadata", + "org.dspace.saml.PHONE => phone," + + "org.dspace.saml.NICKNAME => nickname"); + + context.setCurrentUser(null); + context.turnOffAuthorisationSystem(); + + testUser = EPersonBuilder.createEPerson(context) + .withEmail("alyssa@dspace.org") + .withNetId("001") + .withNameInMetadata("Alyssa", "Hacker") + .withCanLogin(true) + .build(); + + context.restoreAuthSystemState(); + + request.setAttribute("org.dspace.saml.NAME_ID", "001"); + request.setAttribute("org.dspace.saml.PHONE", "123-456-7890"); + request.setAttribute("org.dspace.saml.NICKNAME", "Liz"); + + int result = samlAuth.authenticate(context, null, null, null, request); + + assertEquals(AuthenticationMethod.SUCCESS, result); + + EPerson user = context.getCurrentUser(); + + assertNotNull(user); + assertEquals("alyssa@dspace.org", user.getEmail()); + assertEquals("001", user.getNetid()); + assertEquals("Alyssa", user.getFirstName()); + assertEquals("Hacker", user.getLastName()); + + List metadata = user.getMetadata(); + + assertEquals(4, metadata.size()); + assertEquals("eperson_phone", metadata.get(2).getMetadataField().toString()); + assertEquals("123-456-7890", metadata.get(2).getValue()); + assertEquals("eperson_nickname", metadata.get(3).getMetadataField().toString()); + assertEquals("Liz", metadata.get(3).getValue()); + } + + @Test + public void testInvalidAdditionalMetadataMappingsAreIgnored() throws Exception { + configurationService.setProperty("authentication-saml.eperson.metadata", + "oops this is bad," + + "org.dspace.saml.NICKNAME => nickname"); + + context.setCurrentUser(null); + context.turnOffAuthorisationSystem(); + + testUser = EPersonBuilder.createEPerson(context) + .withEmail("alyssa@dspace.org") + .withNetId("001") + .withNameInMetadata("Alyssa", "Hacker") + .withCanLogin(true) + .build(); + + context.restoreAuthSystemState(); + + request.setAttribute("org.dspace.saml.NAME_ID", "001"); + request.setAttribute("org.dspace.saml.PHONE", "123-456-7890"); + request.setAttribute("org.dspace.saml.NICKNAME", "Liz"); + + int result = samlAuth.authenticate(context, null, null, null, request); + + assertEquals(AuthenticationMethod.SUCCESS, result); + + EPerson user = context.getCurrentUser(); + + assertNotNull(user); + assertEquals("alyssa@dspace.org", user.getEmail()); + assertEquals("001", user.getNetid()); + assertEquals("Alyssa", user.getFirstName()); + assertEquals("Hacker", user.getLastName()); + + List metadata = user.getMetadata(); + + assertEquals(3, metadata.size()); + assertEquals("eperson_nickname", metadata.get(2).getMetadataField().toString()); + assertEquals("Liz", metadata.get(2).getValue()); + } + + @Test + public void testAuthenticateExistingUserAdditionalMetadataAutocreateDisabled() throws Exception { + configurationService.setProperty("authentication-saml.eperson.metadata.autocreate", false); + + configurationService.setProperty("authentication-saml.eperson.metadata", + "org.dspace.saml.PHONE => phone," + + "org.dspace.saml.DEPARTMENT => department"); + + context.setCurrentUser(null); + context.turnOffAuthorisationSystem(); + + testUser = EPersonBuilder.createEPerson(context) + .withEmail("alyssa@dspace.org") + .withNetId("001") + .withNameInMetadata("Alyssa", "Hacker") + .withCanLogin(true) + .build(); + + context.restoreAuthSystemState(); + + request.setAttribute("org.dspace.saml.NAME_ID", "001"); + request.setAttribute("org.dspace.saml.PHONE", "123-456-7890"); + request.setAttribute("org.dspace.saml.DEPARTMENT", "Library"); + + int result = samlAuth.authenticate(context, null, null, null, request); + + assertEquals(AuthenticationMethod.SUCCESS, result); + + EPerson user = context.getCurrentUser(); + + assertNotNull(user); + assertEquals("alyssa@dspace.org", user.getEmail()); + assertEquals("001", user.getNetid()); + assertEquals("Alyssa", user.getFirstName()); + assertEquals("Hacker", user.getLastName()); + + List metadata = user.getMetadata(); + + assertEquals(3, metadata.size()); + assertEquals("eperson_phone", metadata.get(2).getMetadataField().toString()); + assertEquals("123-456-7890", metadata.get(2).getValue()); + } + + @Test + public void testAdditionalMetadataWithInvalidNameNotAutocreated() throws Exception { + configurationService.setProperty("authentication-saml.eperson.metadata", + "org.dspace.saml.PHONE => phone," + + "org.dspace.saml.DEPARTMENT => (department)"); // parens not allowed + + context.setCurrentUser(null); + context.turnOffAuthorisationSystem(); + + testUser = EPersonBuilder.createEPerson(context) + .withEmail("alyssa@dspace.org") + .withNetId("001") + .withNameInMetadata("Alyssa", "Hacker") + .withCanLogin(true) + .build(); + + context.restoreAuthSystemState(); + + request.setAttribute("org.dspace.saml.NAME_ID", "001"); + request.setAttribute("org.dspace.saml.PHONE", "123-456-7890"); + request.setAttribute("org.dspace.saml.DEPARTMENT", "Library"); + + int result = samlAuth.authenticate(context, null, null, null, request); + + assertEquals(AuthenticationMethod.SUCCESS, result); + + EPerson user = context.getCurrentUser(); + + assertNotNull(user); + assertEquals("alyssa@dspace.org", user.getEmail()); + assertEquals("001", user.getNetid()); + assertEquals("Alyssa", user.getFirstName()); + assertEquals("Hacker", user.getLastName()); + + List metadata = user.getMetadata(); + + assertEquals(3, metadata.size()); + assertEquals("eperson_phone", metadata.get(2).getMetadataField().toString()); + assertEquals("123-456-7890", metadata.get(2).getValue()); + } + + @Test + public void testExistingUserLoginDisabled() throws Exception { + EPerson originalUser = context.getCurrentUser(); + + context.turnOffAuthorisationSystem(); + + testUser = EPersonBuilder.createEPerson(context) + .withEmail("alyssa@dspace.org") + .withNameInMetadata("Alyssa", "Hacker") + .withCanLogin(false) + .build(); + + context.restoreAuthSystemState(); + + request.setAttribute("org.dspace.saml.EMAIL", List.of("alyssa@dspace.org")); + + int result = samlAuth.authenticate(context, null, null, null, request); + + assertEquals(AuthenticationMethod.BAD_ARGS, result); + assertEquals(originalUser, context.getCurrentUser()); + } + + @Test + public void testNonExistentUserWithoutEmail() throws Exception { + EPerson originalUser = context.getCurrentUser(); + + request.setAttribute("org.dspace.saml.NAME_ID", "non-existent-netid"); + + int result = samlAuth.authenticate(context, null, null, null, request); + + assertEquals(AuthenticationMethod.NO_SUCH_USER, result); + assertEquals(originalUser, context.getCurrentUser()); + } + + @Test + public void testNonExistentUserWithEmailAutoregisterEnabled() throws Exception { + context.setCurrentUser(null); + + request.setAttribute("org.dspace.saml.NAME_ID", "non-existent-netid"); + request.setAttribute("org.dspace.saml.EMAIL", List.of("ben@dspace.org")); + request.setAttribute("org.dspace.saml.GIVEN_NAME", "Ben"); + request.setAttribute("org.dspace.saml.SURNAME", "Bitdiddle"); + + int result = samlAuth.authenticate(context, null, null, null, request); + + assertEquals(AuthenticationMethod.SUCCESS, result); + + EPerson user = context.getCurrentUser(); + + assertNotNull(user); + assertEquals("ben@dspace.org", user.getEmail()); + assertEquals("non-existent-netid", user.getNetid()); + assertEquals("Ben", user.getFirstName()); + assertEquals("Bitdiddle", user.getLastName()); + assertTrue(user.canLogIn()); + assertTrue(user.getSelfRegistered()); + + testUser = user; // Make sure the autoregistered user gets deleted. + } + + @Test + public void testNonExistentUserWithEmailAutoregisterDisabled() throws Exception { + configurationService.setProperty("authentication-saml.autoregister", false); + + EPerson originalUser = context.getCurrentUser(); + + request.setAttribute("org.dspace.saml.NAME_ID", "non-existent-netid"); + request.setAttribute("org.dspace.saml.EMAIL", List.of("ben@dspace.org")); + request.setAttribute("org.dspace.saml.GIVEN_NAME", "Ben"); + request.setAttribute("org.dspace.saml.SURNAME", "Bitdiddle"); + + int result = samlAuth.authenticate(context, null, null, null, request); + + assertEquals(AuthenticationMethod.NO_SUCH_USER, result); + assertEquals(originalUser, context.getCurrentUser()); + } + + @Test + public void testNoEmailOrNameIdInRequest() throws Exception { + context.setCurrentUser(null); + context.turnOffAuthorisationSystem(); + + testUser = EPersonBuilder.createEPerson(context) + .withEmail("alyssa@dspace.org") + .withNameInMetadata("Alyssa", "Hacker") + .withCanLogin(true) + .build(); + + context.restoreAuthSystemState(); + + int result = samlAuth.authenticate(context, null, null, null, request); + + assertEquals(AuthenticationMethod.NO_SUCH_USER, result); + } + + @Test + public void testRequestIsNull() throws Exception { + EPerson originalUser = context.getCurrentUser(); + + int result = samlAuth.authenticate(context, null, null, null, null); + + assertEquals(AuthenticationMethod.BAD_ARGS, result); + assertEquals(originalUser, context.getCurrentUser()); + } +} diff --git a/dspace-api/src/test/java/org/dspace/builder/OrcidHistoryBuilder.java b/dspace-api/src/test/java/org/dspace/builder/OrcidHistoryBuilder.java index 199f412f85..d811d03f53 100644 --- a/dspace-api/src/test/java/org/dspace/builder/OrcidHistoryBuilder.java +++ b/dspace-api/src/test/java/org/dspace/builder/OrcidHistoryBuilder.java @@ -11,7 +11,8 @@ import java.io.IOException; import java.sql.SQLException; import java.util.Date; -import org.apache.log4j.Logger; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.dspace.content.Item; import org.dspace.core.Context; import org.dspace.orcid.OrcidHistory; @@ -24,7 +25,7 @@ import org.dspace.orcid.service.OrcidHistoryService; */ public class OrcidHistoryBuilder extends AbstractBuilder { - private static final Logger log = Logger.getLogger(OrcidHistoryBuilder.class); + private static final Logger log = LogManager.getLogger(OrcidHistoryBuilder.class); private OrcidHistory orcidHistory; diff --git a/dspace-api/src/test/java/org/dspace/content/VersioningWithRelationshipsIT.java b/dspace-api/src/test/java/org/dspace/content/VersioningWithRelationshipsIT.java index 3acc4ca146..9ff452f789 100644 --- a/dspace-api/src/test/java/org/dspace/content/VersioningWithRelationshipsIT.java +++ b/dspace-api/src/test/java/org/dspace/content/VersioningWithRelationshipsIT.java @@ -59,7 +59,7 @@ import org.dspace.content.virtual.Collected; import org.dspace.content.virtual.VirtualMetadataConfiguration; import org.dspace.content.virtual.VirtualMetadataPopulator; import org.dspace.core.Constants; -import org.dspace.discovery.SolrSearchCore; +import org.dspace.discovery.MockSolrSearchCore; import org.dspace.kernel.ServiceManager; import org.dspace.services.factory.DSpaceServicesFactory; import org.dspace.versioning.Version; @@ -79,8 +79,9 @@ public class VersioningWithRelationshipsIT extends AbstractIntegrationTestWithDa ContentServiceFactory.getInstance().getInstallItemService(); private final ItemService itemService = ContentServiceFactory.getInstance().getItemService(); - private final SolrSearchCore solrSearchCore = - DSpaceServicesFactory.getInstance().getServiceManager().getServicesByType(SolrSearchCore.class).get(0); + private final MockSolrSearchCore solrSearchCore = + DSpaceServicesFactory.getInstance().getServiceManager().getServiceByName(null, MockSolrSearchCore.class); + protected Community community; protected Collection collection; protected EntityType publicationEntityType; diff --git a/dspace-api/src/test/java/org/dspace/content/WorkspaceItemTest.java b/dspace-api/src/test/java/org/dspace/content/WorkspaceItemTest.java index d018a15f97..15d4720c93 100644 --- a/dspace-api/src/test/java/org/dspace/content/WorkspaceItemTest.java +++ b/dspace-api/src/test/java/org/dspace/content/WorkspaceItemTest.java @@ -12,6 +12,7 @@ import static org.hamcrest.CoreMatchers.notNullValue; import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; @@ -39,6 +40,7 @@ import org.dspace.core.Context; import org.dspace.eperson.EPerson; import org.dspace.eperson.factory.EPersonServiceFactory; import org.dspace.eperson.service.EPersonService; +import org.dspace.workflow.MockWorkflowItem; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -468,4 +470,14 @@ public class WorkspaceItemTest extends AbstractUnitTest { assertTrue("testSetPublishedBefore 0", wi.isPublishedBefore()); } + @Test + public void testDuplicateItemID() throws Exception { + context.turnOffAuthorisationSystem(); + Item item = wi.getItem(); + MockWorkflowItem wfItem = new MockWorkflowItem(); + wfItem.item = item; + wfItem.collection = collection; + assertThrows(IllegalArgumentException.class, () -> workspaceItemService.create(context, wfItem)); + context.restoreAuthSystemState(); + } } diff --git a/dspace-api/src/test/java/org/dspace/content/authority/DSpaceControlledVocabularyTest.java b/dspace-api/src/test/java/org/dspace/content/authority/DSpaceControlledVocabularyTest.java index 255b070e5e..43bd20cc15 100644 --- a/dspace-api/src/test/java/org/dspace/content/authority/DSpaceControlledVocabularyTest.java +++ b/dspace-api/src/test/java/org/dspace/content/authority/DSpaceControlledVocabularyTest.java @@ -89,6 +89,145 @@ public class DSpaceControlledVocabularyTest extends AbstractDSpaceTest { assertEquals("north 40", result.values[0].value); } + /** + * Test of getMatches method of class + * DSpaceControlledVocabulary using a localized controlled vocabulary with no locale (fallback to default) + * @throws java.lang.ClassNotFoundException passed through. + */ + @Test + public void testGetMatchesNoLocale() throws ClassNotFoundException { + final String PLUGIN_INTERFACE = "org.dspace.content.authority.ChoiceAuthority"; + + String idValue = "DZA"; + String labelPart = "Alge"; + int start = 0; + int limit = 10; + // This "countries" Controlled Vocab is included in TestEnvironment data + // (under /src/test/data/dspaceFolder/) and it should be auto-loaded + // by test configs in /src/test/data/dspaceFolder/config/local.cfg + DSpaceControlledVocabulary instance = (DSpaceControlledVocabulary) + CoreServiceFactory.getInstance().getPluginService().getNamedPlugin(Class.forName(PLUGIN_INTERFACE), + "countries"); + assertNotNull(instance); + Choices result = instance.getMatches(labelPart, start, limit, null); + assertEquals(idValue, result.values[0].value); + assertEquals("Algeria", result.values[0].label); + } + + /** + * Test of getBestMatch method of class + * DSpaceControlledVocabulary using a localized controlled vocabulary with no locale (fallback to default) + * @throws java.lang.ClassNotFoundException passed through. + */ + @Test + public void testGetBestMatchIdValueNoLocale() throws ClassNotFoundException { + final String PLUGIN_INTERFACE = "org.dspace.content.authority.ChoiceAuthority"; + + String idValue = "DZA"; + // This "countries" Controlled Vocab is included in TestEnvironment data + // (under /src/test/data/dspaceFolder/) and it should be auto-loaded + // by test configs in /src/test/data/dspaceFolder/config/local.cfg + DSpaceControlledVocabulary instance = (DSpaceControlledVocabulary) + CoreServiceFactory.getInstance().getPluginService().getNamedPlugin(Class.forName(PLUGIN_INTERFACE), + "countries"); + assertNotNull(instance); + Choices result = instance.getBestMatch(idValue, null); + assertEquals(idValue, result.values[0].value); + assertEquals("Algeria", result.values[0].label); + } + + /** + * Test of getMatches method of class + * DSpaceControlledVocabulary using a localized controlled vocabulary with valid locale parameter (localized + * label returned) + */ + @Test + public void testGetMatchesGermanLocale() throws ClassNotFoundException { + final String PLUGIN_INTERFACE = "org.dspace.content.authority.ChoiceAuthority"; + + String idValue = "DZA"; + String labelPart = "Alge"; + int start = 0; + int limit = 10; + // This "countries" Controlled Vocab is included in TestEnvironment data + // (under /src/test/data/dspaceFolder/) and it should be auto-loaded + // by test configs in /src/test/data/dspaceFolder/config/local.cfg + DSpaceControlledVocabulary instance = (DSpaceControlledVocabulary) + CoreServiceFactory.getInstance().getPluginService().getNamedPlugin(Class.forName(PLUGIN_INTERFACE), + "countries"); + assertNotNull(instance); + Choices result = instance.getMatches(labelPart, start, limit, "de"); + assertEquals(idValue, result.values[0].value); + assertEquals("Algerien", result.values[0].label); + } + + /** + * Test of getBestMatch method of class + * DSpaceControlledVocabulary using a localized controlled vocabulary with valid locale parameter (localized + * label returned) + */ + @Test + public void testGetBestMatchIdValueGermanLocale() throws ClassNotFoundException { + final String PLUGIN_INTERFACE = "org.dspace.content.authority.ChoiceAuthority"; + + String idValue = "DZA"; + // This "countries" Controlled Vocab is included in TestEnvironment data + // (under /src/test/data/dspaceFolder/) and it should be auto-loaded + // by test configs in /src/test/data/dspaceFolder/config/local.cfg + DSpaceControlledVocabulary instance = (DSpaceControlledVocabulary) + CoreServiceFactory.getInstance().getPluginService().getNamedPlugin(Class.forName(PLUGIN_INTERFACE), + "countries"); + assertNotNull(instance); + Choices result = instance.getBestMatch(idValue, "de"); + assertEquals(idValue, result.values[0].value); + assertEquals("Algerien", result.values[0].label); + } + + /** + * Test of getChoice method of class + * DSpaceControlledVocabulary using a localized controlled vocabulary with no locale (fallback to default) + * @throws java.lang.ClassNotFoundException passed through. + */ + @Test + public void testGetChoiceNoLocale() throws ClassNotFoundException { + final String PLUGIN_INTERFACE = "org.dspace.content.authority.ChoiceAuthority"; + + String idValue = "DZA"; + // This "countries" Controlled Vocab is included in TestEnvironment data + // (under /src/test/data/dspaceFolder/) and it should be auto-loaded + // by test configs in /src/test/data/dspaceFolder/config/local.cfg + DSpaceControlledVocabulary instance = (DSpaceControlledVocabulary) + CoreServiceFactory.getInstance().getPluginService().getNamedPlugin(Class.forName(PLUGIN_INTERFACE), + "countries"); + assertNotNull(instance); + Choice result = instance.getChoice(idValue, null); + assertEquals(idValue, result.value); + assertEquals("Algeria", result.label); + } + + /** + * Test of getChoice method of class + * DSpaceControlledVocabulary using a localized controlled vocabulary with valid locale parameter (localized + * label returned) + * @throws java.lang.ClassNotFoundException passed through. + */ + @Test + public void testGetChoiceGermanLocale() throws ClassNotFoundException { + final String PLUGIN_INTERFACE = "org.dspace.content.authority.ChoiceAuthority"; + + String idValue = "DZA"; + // This "countries" Controlled Vocab is included in TestEnvironment data + // (under /src/test/data/dspaceFolder/) and it should be auto-loaded + // by test configs in /src/test/data/dspaceFolder/config/local.cfg + DSpaceControlledVocabulary instance = (DSpaceControlledVocabulary) + CoreServiceFactory.getInstance().getPluginService().getNamedPlugin(Class.forName(PLUGIN_INTERFACE), + "countries"); + assertNotNull(instance); + Choice result = instance.getChoice(idValue, "de"); + assertEquals(idValue, result.value); + assertEquals("Algerien", result.label); + } + /** * Test of getBestMatch method, of class DSpaceControlledVocabulary. */ diff --git a/dspace-api/src/test/java/org/dspace/content/service/ItemServiceIT.java b/dspace-api/src/test/java/org/dspace/content/service/ItemServiceIT.java index eee445b333..02154e715c 100644 --- a/dspace-api/src/test/java/org/dspace/content/service/ItemServiceIT.java +++ b/dspace-api/src/test/java/org/dspace/content/service/ItemServiceIT.java @@ -11,6 +11,7 @@ import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasSize; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -990,4 +991,38 @@ public class ItemServiceIT extends AbstractIntegrationTestWithDatabase { context.restoreAuthSystemState(); } + @Test + public void testIsLatestVersion() throws Exception { + assertTrue("Original should be the latest version", this.itemService.isLatestVersion(context, item)); + + context.turnOffAuthorisationSystem(); + + Version firstVersion = versioningService.createNewVersion(context, item); + Item firstPublication = firstVersion.getItem(); + WorkspaceItem firstPublicationWSI = workspaceItemService.findByItem(context, firstPublication); + installItemService.installItem(context, firstPublicationWSI); + + context.commit(); + context.restoreAuthSystemState(); + + assertTrue("First version should be valid", this.itemService.isLatestVersion(context, firstPublication)); + assertFalse("Original version should not be valid", this.itemService.isLatestVersion(context, item)); + + context.turnOffAuthorisationSystem(); + + Version secondVersion = versioningService.createNewVersion(context, item); + Item secondPublication = secondVersion.getItem(); + WorkspaceItem secondPublicationWSI = workspaceItemService.findByItem(context, secondPublication); + installItemService.installItem(context, secondPublicationWSI); + + context.commit(); + context.restoreAuthSystemState(); + + assertTrue("Second version should be valid", this.itemService.isLatestVersion(context, secondPublication)); + assertFalse("First version should not be valid", this.itemService.isLatestVersion(context, firstPublication)); + assertFalse("Original version should not be valid", this.itemService.isLatestVersion(context, item)); + + context.turnOffAuthorisationSystem(); + } + } diff --git a/dspace-api/src/test/java/org/dspace/core/ContextTest.java b/dspace-api/src/test/java/org/dspace/core/ContextTest.java index c6cd849d21..ccc1d2f732 100644 --- a/dspace-api/src/test/java/org/dspace/core/ContextTest.java +++ b/dspace-api/src/test/java/org/dspace/core/ContextTest.java @@ -558,4 +558,29 @@ public class ContextTest extends AbstractUnitTest { cleanupContext(instance); } + @Test + public void testUncacheEntities() throws Throwable { + // To set up the test, ensure the cache contains more than the current user entity + groupService.findByName(context, Group.ANONYMOUS); + assertTrue("Cache size should be greater than one", context.getDBConnection().getCacheSize() > 1); + + context.uncacheEntities(); + + assertThat("Cache size should be one (current user)", context.getDBConnection().getCacheSize(), equalTo(1L)); + context.reloadEntity(context.getCurrentUser()); + assertThat("Cache should only contain the current user", context.getDBConnection().getCacheSize(), equalTo(1L)); + } + + @Test + public void testUncacheEntity() throws Throwable { + // Remember the cache size after loading an entity + Group group = groupService.findByName(context, Group.ANONYMOUS); + long oldCacheSize = context.getDBConnection().getCacheSize(); + + // Uncache the entity + context.uncacheEntity(group); + + long newCacheSize = context.getDBConnection().getCacheSize(); + assertThat("Cache size should be reduced by one", newCacheSize, equalTo(oldCacheSize - 1)); + } } diff --git a/dspace-api/src/test/java/org/dspace/core/HibernateDBConnectionTest.java b/dspace-api/src/test/java/org/dspace/core/HibernateDBConnectionTest.java index 093f693d56..302844ce62 100644 --- a/dspace-api/src/test/java/org/dspace/core/HibernateDBConnectionTest.java +++ b/dspace-api/src/test/java/org/dspace/core/HibernateDBConnectionTest.java @@ -205,6 +205,28 @@ public class HibernateDBConnectionTest extends AbstractUnitTest { .contains(person)); } + /** + * Test of uncacheEntities method + */ + @Test + public void testUncacheEntities() throws SQLException { + // Get DBConnection associated with DSpace Context + HibernateDBConnection dbConnection = (HibernateDBConnection) context.getDBConnection(); + EPerson person = context.getCurrentUser(); + + assertTrue("Current user should be cached in session", dbConnection.getSession() + .contains(person)); + + dbConnection.uncacheEntities(); + assertFalse("Current user should be gone from cache", dbConnection.getSession() + .contains(person)); + + // Test ability to reload an uncached entity + person = dbConnection.reloadEntity(person); + assertTrue("Current user should be cached back in session", dbConnection.getSession() + .contains(person)); + } + /** * Test of uncacheEntity method */ diff --git a/dspace-api/src/test/java/org/dspace/ctask/general/CreateMissingIdentifiersIT.java b/dspace-api/src/test/java/org/dspace/ctask/general/CreateMissingIdentifiersIT.java index 8038a71533..3b50258a5a 100644 --- a/dspace-api/src/test/java/org/dspace/ctask/general/CreateMissingIdentifiersIT.java +++ b/dspace-api/src/test/java/org/dspace/ctask/general/CreateMissingIdentifiersIT.java @@ -10,10 +10,7 @@ package org.dspace.ctask.general; import static org.junit.Assert.assertEquals; import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import org.dspace.AbstractIntegrationTestWithDatabase; import org.dspace.builder.CollectionBuilder; import org.dspace.builder.CommunityBuilder; import org.dspace.builder.ItemBuilder; @@ -21,13 +18,11 @@ import org.dspace.content.Collection; import org.dspace.content.Item; import org.dspace.core.factory.CoreServiceFactory; import org.dspace.curate.Curator; -import org.dspace.identifier.IdentifierProvider; -import org.dspace.identifier.IdentifierServiceImpl; +import org.dspace.identifier.AbstractIdentifierProviderIT; +import org.dspace.identifier.VersionedHandleIdentifierProvider; import org.dspace.identifier.VersionedHandleIdentifierProviderWithCanonicalHandles; -import org.dspace.kernel.ServiceManager; import org.dspace.services.ConfigurationService; import org.dspace.services.factory.DSpaceServicesFactory; -import org.junit.After; import org.junit.Test; /** @@ -36,30 +31,19 @@ import org.junit.Test; * @author mwood */ public class CreateMissingIdentifiersIT - extends AbstractIntegrationTestWithDatabase { - private ServiceManager serviceManager; - private IdentifierServiceImpl identifierService; + extends AbstractIdentifierProviderIT { + private static final String P_TASK_DEF = "plugin.named.org.dspace.curate.CurationTask"; private static final String TASK_NAME = "test"; - @Override - public void setUp() throws Exception { - super.setUp(); - context.turnOffAuthorisationSystem(); - - serviceManager = DSpaceServicesFactory.getInstance().getServiceManager(); - identifierService = serviceManager.getServicesByType(IdentifierServiceImpl.class).get(0); - // Clean out providers to avoid any being used for creation of community and collection - identifierService.setProviders(new ArrayList<>()); - } + private ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); @Test public void testPerform() throws IOException { // Must remove any cached named plugins before creating a new one CoreServiceFactory.getInstance().getPluginService().clearNamedPluginClasses(); - ConfigurationService configurationService = kernelImpl.getConfigurationService(); // Define a new task dynamically configurationService.setProperty(P_TASK_DEF, CreateMissingIdentifiers.class.getCanonicalName() + " = " + TASK_NAME); @@ -76,14 +60,7 @@ public class CreateMissingIdentifiersIT .build(); /* - * Curate with regular test configuration -- should succeed. - */ - curator.curate(context, item); - int status = curator.getStatus(TASK_NAME); - assertEquals("Curation should succeed", Curator.CURATE_SUCCESS, status); - - /* - * Now install an incompatible provider to make the task fail. + * First, install an incompatible provider to make the task fail. */ registerProvider(VersionedHandleIdentifierProviderWithCanonicalHandles.class); @@ -92,22 +69,18 @@ public class CreateMissingIdentifiersIT curator.getResult(TASK_NAME)); assertEquals("Curation should fail", Curator.CURATE_ERROR, curator.getStatus(TASK_NAME)); - } - @Override - @After - public void destroy() throws Exception { - super.destroy(); - DSpaceServicesFactory.getInstance().getServiceManager().getApplicationContext().refresh(); - } + // Unregister this non-default provider + unregisterProvider(VersionedHandleIdentifierProviderWithCanonicalHandles.class); + // Re-register the default provider (for later tests which may depend on it) + registerProvider(VersionedHandleIdentifierProvider.class); - private void registerProvider(Class type) { - // Register our new provider - serviceManager.registerServiceClass(type.getName(), type); - IdentifierProvider identifierProvider = - (IdentifierProvider) serviceManager.getServiceByName(type.getName(), type); - - // Overwrite the identifier-service's providers with the new one to ensure only this provider is used - identifierService.setProviders(List.of(identifierProvider)); + /* + * Now, verify curate with default Handle Provider works + * (and that our re-registration of the default provider above was successful) + */ + curator.curate(context, item); + int status = curator.getStatus(TASK_NAME); + assertEquals("Curation should succeed", Curator.CURATE_SUCCESS, status); } } diff --git a/dspace-api/src/test/java/org/dspace/discovery/DiscoveryIT.java b/dspace-api/src/test/java/org/dspace/discovery/DiscoveryIT.java index 6bc79cad55..63ff93b6f3 100644 --- a/dspace-api/src/test/java/org/dspace/discovery/DiscoveryIT.java +++ b/dspace-api/src/test/java/org/dspace/discovery/DiscoveryIT.java @@ -8,6 +8,10 @@ package org.dspace.discovery; import static org.dspace.discovery.SolrServiceWorkspaceWorkflowRestrictionPlugin.DISCOVER_WORKSPACE_CONFIGURATION_NAME; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.not; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -21,6 +25,10 @@ import java.util.List; import java.util.stream.Collectors; import jakarta.servlet.http.HttpServletRequest; +import org.apache.solr.client.solrj.SolrQuery; +import org.apache.solr.client.solrj.SolrServerException; +import org.apache.solr.client.solrj.response.QueryResponse; +import org.apache.solr.common.SolrDocument; import org.dspace.AbstractIntegrationTestWithDatabase; import org.dspace.app.launcher.ScriptLauncher; import org.dspace.app.scripts.handler.impl.TestDSpaceRunnableHandler; @@ -99,6 +107,9 @@ public class DiscoveryIT extends AbstractIntegrationTestWithDatabase { MetadataAuthorityService metadataAuthorityService = ContentAuthorityServiceFactory.getInstance() .getMetadataAuthorityService(); + MockSolrSearchCore solrSearchCore = DSpaceServicesFactory.getInstance().getServiceManager() + .getServiceByName(null, MockSolrSearchCore.class); + @Override @Before public void setUp() throws Exception { @@ -796,6 +807,104 @@ public class DiscoveryIT extends AbstractIntegrationTestWithDatabase { } } + /** + * Test designed to check if the submitter is not indexed in all in solr documents for items + * and the submitter authority is still indexed + * @throws SearchServiceException + */ + @Test + public void searchWithNoSubmitterTest() throws SearchServiceException { + + configurationService.setProperty("discovery.index.item.submitter.enabled", false); + DiscoveryConfiguration defaultConf = SearchUtils.getDiscoveryConfiguration(context, "default", null); + + // Populate the testing objects: create items in eperson's workspace and perform search in it + int numberItems = 10; + context.turnOffAuthorisationSystem(); + EPerson submitter = null; + try { + submitter = EPersonBuilder.createEPerson(context).withEmail("submitter@example.org") + .withNameInMetadata("Peter", "Funny").build(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + context.setCurrentUser(submitter); + Community community = CommunityBuilder.createCommunity(context).build(); + Collection collection = CollectionBuilder.createCollection(context, community).build(); + for (int i = 0; i < numberItems; i++) { + ItemBuilder.createItem(context, collection) + .withTitle("item " + i) + .build(); + } + context.restoreAuthSystemState(); + + // Build query with default parameters (except for workspaceConf) + QueryResponse result = null; + try { + result = solrSearchCore.getSolr().query(new SolrQuery(String.format( + "search.resourcetype:\"Item\""))); + } catch (SolrServerException e) { + throw new RuntimeException(e); + } catch (IOException e) { + throw new RuntimeException(e); + } + assertEquals(result.getResults().size(), numberItems); + for (SolrDocument doc : result.getResults()) { + assertThat(doc.getFieldNames(), + not(hasItems("submitter_keyword", "submitter_ac", "submitter_acid", "submitter_filter"))); + assertThat(doc.getFieldNames(), hasItem("submitter_authority")); + } + } + + /** + * Test designed to check if the submitter is indexed in all in solr documents for items + * @throws SearchServiceException + */ + @Test + public void searchWithSubmitterTest() throws SearchServiceException { + + configurationService.setProperty("discovery.index.item.submitter.enabled", true); + DiscoveryConfiguration defaultConf = SearchUtils.getDiscoveryConfiguration(context, "default", null); + + // Populate the testing objects: create items in eperson's workspace and perform search in it + int numberItems = 10; + context.turnOffAuthorisationSystem(); + EPerson submitter = null; + try { + submitter = EPersonBuilder.createEPerson(context).withEmail("submitter@example.org") + .withNameInMetadata("Peter", "Funny").build(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + context.setCurrentUser(submitter); + Community community = CommunityBuilder.createCommunity(context).build(); + Collection collection = CollectionBuilder.createCollection(context, community).build(); + for (int i = 0; i < numberItems; i++) { + ItemBuilder.createItem(context, collection) + .withTitle("item " + i) + .build(); + } + context.restoreAuthSystemState(); + + // Build query with default parameters (except for workspaceConf) + QueryResponse result = null; + try { + result = solrSearchCore.getSolr().query(new SolrQuery(String.format( + "search.resourcetype:\"Item\""))); + } catch (SolrServerException e) { + throw new RuntimeException(e); + } catch (IOException e) { + throw new RuntimeException(e); + } + assertEquals(result.getResults().size(), numberItems); + for (SolrDocument doc : result.getResults()) { + for (String fieldname : doc.getFieldNames()) { + assertThat(doc.getFieldNames(), hasItems("submitter_keyword","submitter_ac", "submitter_filter", + "submitter_authority")); + } + } + } + private void assertSearchQuery(String resourceType, int size) throws SearchServiceException { assertSearchQuery(resourceType, size, size, 0, -1); } diff --git a/dspace-api/src/test/java/org/dspace/identifier/AbstractIdentifierProviderIT.java b/dspace-api/src/test/java/org/dspace/identifier/AbstractIdentifierProviderIT.java new file mode 100644 index 0000000000..6ec9efddf5 --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/identifier/AbstractIdentifierProviderIT.java @@ -0,0 +1,68 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.identifier; + +import java.util.ArrayList; +import java.util.List; + +import org.dspace.AbstractIntegrationTestWithDatabase; +import org.dspace.kernel.ServiceManager; +import org.dspace.services.factory.DSpaceServicesFactory; + +/** + * AbstractIdentifierProviderIT which contains a few useful utility methods for IdentifierProvider Integration Tests + */ +public class AbstractIdentifierProviderIT extends AbstractIntegrationTestWithDatabase { + + protected final ServiceManager serviceManager = DSpaceServicesFactory.getInstance().getServiceManager(); + protected final IdentifierServiceImpl identifierService = + serviceManager.getServicesByType(IdentifierServiceImpl.class).get(0); + + /** + * Register a specific IdentifierProvider into the current IdentifierService (replacing any existing providers). + * This method will also ensure the IdentifierProvider service is registered in the DSpace Service Manager. + * @param type IdentifierProvider Class + */ + protected void registerProvider(Class type) { + // Register our new provider + IdentifierProvider identifierProvider = + (IdentifierProvider) DSpaceServicesFactory.getInstance().getServiceManager() + .getServiceByName(type.getName(), type); + if (identifierProvider == null) { + DSpaceServicesFactory.getInstance().getServiceManager().registerServiceClass(type.getName(), type); + identifierProvider = (IdentifierProvider) DSpaceServicesFactory.getInstance().getServiceManager() + .getServiceByName(type.getName(), type); + } + + identifierService.setProviders(List.of(identifierProvider)); + } + + /** + * Unregister a specific IdentifierProvider from the current IdentifierService (removing all existing providers). + * This method will also ensure the IdentifierProvider service is unregistered in the DSpace Service Manager, + * which ensures it does not conflict with other IdentifierProvider services. + * @param type IdentifierProvider Class + */ + protected void unregisterProvider(Class type) { + // Find the provider service + IdentifierProvider identifierProvider = + (IdentifierProvider) DSpaceServicesFactory.getInstance().getServiceManager() + .getServiceByName(type.getName(), type); + // If found, unregister it + if (identifierProvider == null) { + DSpaceServicesFactory.getInstance().getServiceManager().unregisterService(type.getName()); + } + + // Overwrite the identifier-service's providers with an empty list + identifierService.setProviders(new ArrayList<>()); + } + +} + + + diff --git a/dspace-api/src/test/java/org/dspace/identifier/VersionedHandleIdentifierProviderIT.java b/dspace-api/src/test/java/org/dspace/identifier/VersionedHandleIdentifierProviderIT.java index d131891aa6..4a868eff66 100644 --- a/dspace-api/src/test/java/org/dspace/identifier/VersionedHandleIdentifierProviderIT.java +++ b/dspace-api/src/test/java/org/dspace/identifier/VersionedHandleIdentifierProviderIT.java @@ -10,13 +10,8 @@ package org.dspace.identifier; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; -import java.lang.reflect.InvocationTargetException; import java.sql.SQLException; -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; -import org.dspace.AbstractIntegrationTestWithDatabase; import org.dspace.authorize.AuthorizeException; import org.dspace.builder.CollectionBuilder; import org.dspace.builder.CommunityBuilder; @@ -24,21 +19,10 @@ import org.dspace.builder.ItemBuilder; import org.dspace.builder.VersionBuilder; import org.dspace.content.Collection; import org.dspace.content.Item; -import org.dspace.kernel.ServiceManager; -import org.dspace.services.factory.DSpaceServicesFactory; -import org.junit.After; import org.junit.Before; import org.junit.Test; -import org.springframework.beans.factory.config.AutowireCapableBeanFactory; -import org.springframework.test.util.ReflectionTestUtils; -public class VersionedHandleIdentifierProviderIT extends AbstractIntegrationTestWithDatabase { - - private List originalProviders; - - private ServiceManager serviceManager; - private IdentifierServiceImpl identifierService; - private List registeredBeans = new ArrayList<>(); +public class VersionedHandleIdentifierProviderIT extends AbstractIdentifierProviderIT { private String firstHandle; @@ -51,12 +35,8 @@ public class VersionedHandleIdentifierProviderIT extends AbstractIntegrationTest @Override public void setUp() throws Exception { super.setUp(); - serviceManager = DSpaceServicesFactory.getInstance().getServiceManager(); - identifierService = serviceManager.getServicesByType(IdentifierServiceImpl.class).get(0); - originalProviders = (List) ReflectionTestUtils.getField(identifierService, "providers"); context.turnOffAuthorisationSystem(); - identifierService.setProviders(List.of()); parentCommunity = CommunityBuilder.createCommunity(context) .withName("Parent Community") .build(); @@ -67,62 +47,6 @@ public class VersionedHandleIdentifierProviderIT extends AbstractIntegrationTest context.restoreAuthSystemState(); } - @After - @Override - public void destroy() throws Exception { - super.destroy(); - // restore providers - identifierService.setProviders(originalProviders); - // clean beans - unregisterBeans(registeredBeans); - } - - private void unregisterBeans(List registeredBeans) { - AutowireCapableBeanFactory factory = - DSpaceServicesFactory.getInstance() - .getServiceManager() - .getApplicationContext() - .getAutowireCapableBeanFactory(); - Iterator iterator = registeredBeans.iterator(); - while (iterator.hasNext()) { - Object registeredBean = iterator.next(); - factory.destroyBean(registeredBean); - iterator.remove(); - registeredBeans.remove(registeredBean); - } - } - - private T registerBean(Class type) - throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { - AutowireCapableBeanFactory factory = - DSpaceServicesFactory.getInstance() - .getServiceManager() - .getApplicationContext() - .getAutowireCapableBeanFactory(); - // Define our special bean for testing the target class. - T bean = type.getDeclaredConstructor() - .newInstance(); - - registeredBeans.add(bean); - - factory.autowireBean(bean); - return bean; - } - - private void registerSingleProvider(IdentifierProvider provider) { - identifierService.setProviders(List.of(provider)); - } - - private T getOrProvide(Class type) - throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException { - List servicesByType = serviceManager.getServicesByType(type); - if (servicesByType == null || servicesByType.isEmpty()) { - servicesByType = List.of(registerBean(type)); - } - return servicesByType.get(0); - } - - private void createVersions() throws SQLException, AuthorizeException { context.turnOffAuthorisationSystem(); @@ -138,9 +62,6 @@ public class VersionedHandleIdentifierProviderIT extends AbstractIntegrationTest @Test public void testDefaultVersionedHandleProvider() throws Exception { - registerSingleProvider( - getOrProvide(VersionedHandleIdentifierProvider.class) - ); createVersions(); // Confirm the original item only has its original handle @@ -156,9 +77,7 @@ public class VersionedHandleIdentifierProviderIT extends AbstractIntegrationTest @Test public void testCanonicalVersionedHandleProvider() throws Exception { - registerSingleProvider( - getOrProvide(VersionedHandleIdentifierProviderWithCanonicalHandles.class) - ); + registerProvider(VersionedHandleIdentifierProviderWithCanonicalHandles.class); createVersions(); // Confirm the original item only has a version handle @@ -171,6 +90,11 @@ public class VersionedHandleIdentifierProviderIT extends AbstractIntegrationTest assertEquals(firstHandle, itemV3.getHandle()); assertEquals(2, itemV3.getHandles().size()); containsHandle(itemV3, firstHandle + ".3"); + + // Unregister this non-default provider + unregisterProvider(VersionedHandleIdentifierProviderWithCanonicalHandles.class); + // Re-register the default provider (for later tests) + registerProvider(VersionedHandleIdentifierProvider.class); } private void containsHandle(Item item, String handle) { diff --git a/dspace-api/src/test/java/org/dspace/orcid/OrcidQueueConsumerIT.java b/dspace-api/src/test/java/org/dspace/orcid/OrcidQueueConsumerIT.java index f2e528d78c..e17fd0072e 100644 --- a/dspace-api/src/test/java/org/dspace/orcid/OrcidQueueConsumerIT.java +++ b/dspace-api/src/test/java/org/dspace/orcid/OrcidQueueConsumerIT.java @@ -23,6 +23,7 @@ import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; import java.sql.SQLException; import java.time.Instant; @@ -41,13 +42,19 @@ import org.dspace.content.EntityType; import org.dspace.content.Item; import org.dspace.content.MetadataValue; import org.dspace.content.RelationshipType; +import org.dspace.content.WorkspaceItem; import org.dspace.content.factory.ContentServiceFactory; +import org.dspace.content.service.InstallItemService; import org.dspace.content.service.ItemService; +import org.dspace.content.service.WorkspaceItemService; import org.dspace.orcid.consumer.OrcidQueueConsumer; import org.dspace.orcid.factory.OrcidServiceFactory; import org.dspace.orcid.service.OrcidQueueService; import org.dspace.services.ConfigurationService; import org.dspace.services.factory.DSpaceServicesFactory; +import org.dspace.utils.DSpace; +import org.dspace.versioning.Version; +import org.dspace.versioning.service.VersioningService; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -64,8 +71,15 @@ public class OrcidQueueConsumerIT extends AbstractIntegrationTestWithDatabase { private ItemService itemService = ContentServiceFactory.getInstance().getItemService(); + private WorkspaceItemService workspaceItemService = ContentServiceFactory.getInstance().getWorkspaceItemService(); + + private InstallItemService installItemService = ContentServiceFactory.getInstance().getInstallItemService(); + private ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + private VersioningService versioningService = new DSpace().getServiceManager() + .getServicesByType(VersioningService.class).get(0); + private Collection profileCollection; @Before @@ -763,6 +777,177 @@ public class OrcidQueueConsumerIT extends AbstractIntegrationTestWithDatabase { } + @Test + public void testOrcidQueueRecordCreationForPublicationWithNotFoundAuthority() throws Exception { + + context.turnOffAuthorisationSystem(); + + Item profile = ItemBuilder.createItem(context, profileCollection) + .withTitle("Test User") + .withOrcidIdentifier("0000-1111-2222-3333") + .withOrcidAccessToken("ab4d18a0-8d9a-40f1-b601-a417255c8d20", eperson) + .withOrcidSynchronizationPublicationsPreference(ALL) + .build(); + + Collection publicationCollection = createCollection("Publications", "Publication"); + + Item publication = ItemBuilder.createItem(context, publicationCollection) + .withTitle("Test publication") + .withAuthor("First User") + .withAuthor("Test User") + .build(); + + EntityType publicationType = EntityTypeBuilder.createEntityTypeBuilder(context, "Publication").build(); + EntityType personType = EntityTypeBuilder.createEntityTypeBuilder(context, "Person").build(); + + RelationshipType isAuthorOfPublication = createRelationshipTypeBuilder(context, personType, publicationType, + "isAuthorOfPublication", + "isPublicationOfAuthor", 0, null, 0, + null).build(); + + RelationshipBuilder.createRelationshipBuilder(context, profile, publication, isAuthorOfPublication).build(); + + context.restoreAuthSystemState(); + context.commit(); + + List orcidQueueRecords = orcidQueueService.findAll(context); + assertThat(orcidQueueRecords, hasSize(1)); + assertThat(orcidQueueRecords.get(0), matches(profile, publication, "Publication", INSERT)); + } + + @Test + public void testOrcidQueueWithItemVersioning() throws Exception { + + context.turnOffAuthorisationSystem(); + + Item profile = ItemBuilder.createItem(context, profileCollection) + .withTitle("Test User") + .withOrcidIdentifier("0000-1111-2222-3333") + .withOrcidAccessToken("ab4d18a0-8d9a-40f1-b601-a417255c8d20", eperson) + .withOrcidSynchronizationPublicationsPreference(ALL) + .build(); + + Collection publicationCollection = createCollection("Publications", "Publication"); + + Item publication = ItemBuilder.createItem(context, publicationCollection) + .withTitle("Test publication") + .withAuthor("Test User") + .build(); + + EntityType publicationType = EntityTypeBuilder.createEntityTypeBuilder(context, "Publication").build(); + EntityType personType = EntityTypeBuilder.createEntityTypeBuilder(context, "Person").build(); + + RelationshipType isAuthorOfPublication = createRelationshipTypeBuilder(context, personType, publicationType, + "isAuthorOfPublication", + "isPublicationOfAuthor", 0, null, 0, + null).build(); + + RelationshipBuilder.createRelationshipBuilder(context, profile, publication, isAuthorOfPublication).build(); + + context.restoreAuthSystemState(); + context.commit(); + + List orcidQueueRecords = orcidQueueService.findAll(context); + assertThat(orcidQueueRecords, hasSize(1)); + assertThat(orcidQueueRecords.get(0), matches(profile, publication, "Publication", INSERT)); + + context.turnOffAuthorisationSystem(); + Version newVersion = versioningService.createNewVersion(context, publication); + context.restoreAuthSystemState(); + Item newPublication = newVersion.getItem(); + assertThat(newPublication.isArchived(), is(false)); + + context.commit(); + + orcidQueueRecords = orcidQueueService.findAll(context); + assertThat(orcidQueueRecords, hasSize(1)); + assertThat(orcidQueueRecords.get(0), matches(profile, publication, "Publication", INSERT)); + + WorkspaceItem workspaceItem = workspaceItemService.findByItem(context, newVersion.getItem()); + context.turnOffAuthorisationSystem(); + + installItemService.installItem(context, workspaceItem); + + context.restoreAuthSystemState(); + context.commit(); + + orcidQueueRecords = orcidQueueService.findAll(context); + assertThat(orcidQueueRecords, hasSize(1)); + assertThat(orcidQueueRecords.get(0), matches(profile, newPublication, "Publication", INSERT)); + } + + @Test + public void testOrcidQueueUpdateWithItemVersioning() throws Exception { + + context.turnOffAuthorisationSystem(); + + Item profile = ItemBuilder.createItem(context, profileCollection) + .withTitle("Test User") + .withOrcidIdentifier("0000-1111-2222-3333") + .withOrcidAccessToken("ab4d18a0-8d9a-40f1-b601-a417255c8d20", eperson) + .withOrcidSynchronizationPublicationsPreference(ALL) + .build(); + + Collection publicationCollection = createCollection("Publications", "Publication"); + + Item publication = ItemBuilder.createItem(context, publicationCollection) + .withTitle("Test publication") + .build(); + + OrcidHistory orcidHistory = OrcidHistoryBuilder.createOrcidHistory(context, profile, publication) + .withDescription("Test publication") + .withOperation(OrcidOperation.INSERT) + .withPutCode("12345") + .withStatus(201) + .build(); + + addMetadata(publication, "dc", "contributor", "author", "Test User", null); + + EntityType publicationType = EntityTypeBuilder.createEntityTypeBuilder(context, "Publication").build(); + EntityType personType = EntityTypeBuilder.createEntityTypeBuilder(context, "Person").build(); + + RelationshipType isAuthorOfPublication = + createRelationshipTypeBuilder( + context, personType, publicationType, + "isAuthorOfPublication", + "isPublicationOfAuthor", 0, null, 0, + null + ).build(); + + RelationshipBuilder.createRelationshipBuilder(context, profile, publication, isAuthorOfPublication).build(); + + context.commit(); + + List orcidQueueRecords = orcidQueueService.findAll(context); + assertThat(orcidQueueRecords, hasSize(1)); + assertThat(orcidQueueRecords.get(0), matches(profile, publication, "Publication", "12345", UPDATE)); + + Version newVersion = versioningService.createNewVersion(context, publication); + Item newPublication = newVersion.getItem(); + assertThat(newPublication.isArchived(), is(false)); + + context.commit(); + + orcidQueueRecords = orcidQueueService.findAll(context); + assertThat(orcidQueueRecords, hasSize(1)); + assertThat(orcidQueueRecords.get(0), matches(profile, publication, "Publication", "12345", UPDATE)); + + WorkspaceItem workspaceItem = workspaceItemService.findByItem(context, newVersion.getItem()); + installItemService.installItem(context, workspaceItem); + + context.commit(); + + context.restoreAuthSystemState(); + + orcidQueueRecords = orcidQueueService.findAll(context); + assertThat(orcidQueueRecords, hasSize(1)); + assertThat(orcidQueueRecords.get(0), matches(profile, newPublication, "Publication", "12345", UPDATE)); + + orcidHistory = context.reloadEntity(orcidHistory); + assertThat(orcidHistory.getEntity(), is(newPublication)); + + } + private void addMetadata(Item item, String schema, String element, String qualifier, String value, String authority) throws Exception { context.turnOffAuthorisationSystem(); diff --git a/dspace-api/src/test/java/org/dspace/util/DSpaceKernelInitializer.java b/dspace-api/src/test/java/org/dspace/util/DSpaceKernelInitializer.java index a6f381bafb..8f9169875a 100644 --- a/dspace-api/src/test/java/org/dspace/util/DSpaceKernelInitializer.java +++ b/dspace-api/src/test/java/org/dspace/util/DSpaceKernelInitializer.java @@ -83,6 +83,7 @@ public class DSpaceKernelInitializer * Initially look for JNDI Resource called "java:/comp/env/dspace.dir". * If not found, use value provided in "dspace.dir" in Spring Environment */ + @SuppressWarnings("BanJNDI") private String getDSpaceHome(ConfigurableEnvironment environment) { // Load the "dspace.dir" property from Spring's configuration. // This gives us the location of our DSpace configuration, which is diff --git a/dspace-api/src/test/java/org/dspace/workflow/MockWorkflowItem.java b/dspace-api/src/test/java/org/dspace/workflow/MockWorkflowItem.java new file mode 100644 index 0000000000..d36ecf7331 --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/workflow/MockWorkflowItem.java @@ -0,0 +1,62 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.workflow; + +import org.dspace.content.Collection; +import org.dspace.content.Item; +import org.dspace.eperson.EPerson; + +public class MockWorkflowItem implements WorkflowItem { + public Integer id; + public Item item; + public Collection collection; + public EPerson submitter; + boolean hasMultipleFiles; + boolean hasMultipleTitles; + boolean isPublishedBefore; + + public Integer getID() { + return id; + } + + public Item getItem() { + return item; + } + + public Collection getCollection() { + return collection; + } + + public EPerson getSubmitter() { + return submitter; + } + + public boolean hasMultipleFiles() { + return hasMultipleFiles; + } + + public void setMultipleFiles(boolean b) { + hasMultipleFiles = b; + } + + public boolean hasMultipleTitles() { + return hasMultipleTitles; + } + + public void setMultipleTitles(boolean b) { + hasMultipleTitles = b; + } + + public boolean isPublishedBefore() { + return isPublishedBefore; + } + + public void setPublishedBefore(boolean b) { + isPublishedBefore = b; + } +} diff --git a/dspace-iiif/pom.xml b/dspace-iiif/pom.xml index 98683cdc9f..9c4c174bf3 100644 --- a/dspace-iiif/pom.xml +++ b/dspace-iiif/pom.xml @@ -44,6 +44,11 @@ org.springframework.boot spring-boot-starter-logging + + + org.springframework + spring-jcl + @@ -106,7 +111,7 @@ de.digitalcollections.iiif iiif-apis - 0.3.10 + 0.3.11 org.javassist diff --git a/dspace-oai/pom.xml b/dspace-oai/pom.xml index dbfaee3f69..9ba9ff3e2f 100644 --- a/dspace-oai/pom.xml +++ b/dspace-oai/pom.xml @@ -65,9 +65,8 @@ - javax.inject - javax.inject - 1 + jakarta.inject + jakarta.inject-api @@ -80,6 +79,11 @@ org.springframework.boot spring-boot-starter-logging + + + org.springframework + spring-jcl + @@ -94,22 +98,9 @@ org.springframework.boot spring-boot-starter-web - - - org.parboiled - parboiled-java - - - - org.parboiled - parboiled-java - 1.3.1 - - org.dspace @@ -128,23 +119,6 @@ org.apache.logging.log4j log4j-api - - org.apache.logging.log4j - log4j-core - - - org.apache.logging.log4j - log4j-web - - - org.apache.logging.log4j - log4j-slf4j-impl - runtime - - - org.apache.logging.log4j - log4j-1.2-api - diff --git a/dspace-oai/src/main/java/org/dspace/xoai/app/CCElementItemCompilePlugin.java b/dspace-oai/src/main/java/org/dspace/xoai/app/CCElementItemCompilePlugin.java index 225d56a4c9..370543029d 100644 --- a/dspace-oai/src/main/java/org/dspace/xoai/app/CCElementItemCompilePlugin.java +++ b/dspace-oai/src/main/java/org/dspace/xoai/app/CCElementItemCompilePlugin.java @@ -11,7 +11,7 @@ import java.util.List; import com.lyncode.xoai.dataprovider.xml.xoai.Element; import com.lyncode.xoai.dataprovider.xml.xoai.Metadata; -import org.apache.commons.lang.StringUtils; +import org.apache.commons.lang3.StringUtils; import org.dspace.content.Item; import org.dspace.core.Context; import org.dspace.license.factory.LicenseServiceFactory; diff --git a/dspace-oai/src/main/java/org/dspace/xoai/app/XOAI.java b/dspace-oai/src/main/java/org/dspace/xoai/app/XOAI.java index 25cc1ee365..5c29b259de 100644 --- a/dspace-oai/src/main/java/org/dspace/xoai/app/XOAI.java +++ b/dspace-oai/src/main/java/org/dspace/xoai/app/XOAI.java @@ -9,7 +9,7 @@ package org.dspace.xoai.app; import static com.lyncode.xoai.dataprovider.core.Granularity.Second; import static java.util.Objects.nonNull; -import static org.apache.commons.lang.StringUtils.EMPTY; +import static org.apache.commons.lang3.StringUtils.EMPTY; import static org.apache.solr.common.params.CursorMarkParams.CURSOR_MARK_PARAM; import static org.apache.solr.common.params.CursorMarkParams.CURSOR_MARK_START; import static org.dspace.xoai.util.ItemUtils.retrieveMetadata; @@ -334,6 +334,11 @@ public class XOAI { server.add(list); server.commit(); list.clear(); + try { + context.uncacheEntities(); + } catch (SQLException ex) { + log.error("Error uncaching entities", ex); + } } } System.out.println("Total: " + i + " items"); diff --git a/dspace-oai/src/main/java/org/dspace/xoai/filter/ItemsWithBitstreamFilter.java b/dspace-oai/src/main/java/org/dspace/xoai/filter/ItemsWithBitstreamFilter.java index 3599c5b9e1..9bf1c65dc9 100644 --- a/dspace-oai/src/main/java/org/dspace/xoai/filter/ItemsWithBitstreamFilter.java +++ b/dspace-oai/src/main/java/org/dspace/xoai/filter/ItemsWithBitstreamFilter.java @@ -9,8 +9,8 @@ package org.dspace.xoai.filter; import java.sql.SQLException; -import org.apache.log4j.LogManager; -import org.apache.log4j.Logger; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.dspace.content.Bundle; import org.dspace.content.Item; import org.dspace.handle.factory.HandleServiceFactory; diff --git a/dspace-oai/src/main/resources/static/style.xsl b/dspace-oai/src/main/resources/static/style.xsl index 17eb865e8f..67aeb975b2 100644 --- a/dspace-oai/src/main/resources/static/style.xsl +++ b/dspace-oai/src/main/resources/static/style.xsl @@ -522,15 +522,14 @@ - + - - + - - + diff --git a/dspace-oai/src/test/java/org/dspace/xoai/tests/integration/xoai/PipelineTest.java b/dspace-oai/src/test/java/org/dspace/xoai/tests/integration/xoai/PipelineTest.java index 0f48824159..0f7ffde0bd 100644 --- a/dspace-oai/src/test/java/org/dspace/xoai/tests/integration/xoai/PipelineTest.java +++ b/dspace-oai/src/test/java/org/dspace/xoai/tests/integration/xoai/PipelineTest.java @@ -13,13 +13,14 @@ import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.MatcherAssert.assertThat; import java.io.InputStream; +import java.nio.charset.Charset; import javax.xml.transform.TransformerFactory; import javax.xml.transform.stream.StreamSource; import com.lyncode.xoai.util.XSLPipeline; +import org.apache.commons.io.IOUtils; import org.dspace.xoai.tests.support.XmlMatcherBuilder; import org.junit.Test; -import org.parboiled.common.FileUtils; public class PipelineTest { private static TransformerFactory factory = TransformerFactory.newInstance(); @@ -28,9 +29,9 @@ public class PipelineTest { public void pipelineTest() throws Exception { InputStream input = PipelineTest.class.getClassLoader().getResourceAsStream("item.xml"); InputStream xslt = PipelineTest.class.getClassLoader().getResourceAsStream("oai_dc.xsl"); - String output = FileUtils.readAllText(new XSLPipeline(input, true) - .apply(factory.newTemplates(new StreamSource(xslt))) - .getTransformed()); + String output = IOUtils.toString(new XSLPipeline(input, true) + .apply(factory.newTemplates(new StreamSource(xslt))) + .getTransformed(), Charset.defaultCharset()); assertThat(output, oai_dc().withXPath("/oai_dc:dc/dc:title", equalTo("Teste"))); diff --git a/dspace-rdf/pom.xml b/dspace-rdf/pom.xml index 45ab69b483..7f214dc088 100644 --- a/dspace-rdf/pom.xml +++ b/dspace-rdf/pom.xml @@ -67,6 +67,11 @@ org.springframework.boot spring-boot-starter-logging + + + org.springframework + spring-jcl + @@ -80,14 +85,6 @@ org.apache.logging.log4j log4j-api - - org.apache.logging.log4j - log4j-core - - - org.apache.logging.log4j - log4j-web - org.apache.commons diff --git a/dspace-saml2/pom.xml b/dspace-saml2/pom.xml new file mode 100644 index 0000000000..aca67aeb6b --- /dev/null +++ b/dspace-saml2/pom.xml @@ -0,0 +1,276 @@ + + 4.0.0 + dspace-saml2 + jar + DSpace SAML 2 + DSpace SAML 2 Extension + + + org.dspace + dspace-parent + 9.0-SNAPSHOT + .. + + + + + ${basedir}/.. + + + + + + + org.codehaus.gmaven + groovy-maven-plugin + + + setproperty + initialize + + execute + + + + project.properties['agnostic.build.dir'] = project.build.directory.replace(File.separator, '/'); + log.info("Initializing Maven property 'agnostic.build.dir' to: {}", project.properties['agnostic.build.dir']); + + + + + + + + + + + + unit-test-environment + + false + + skipUnitTests + false + + + + + + + maven-dependency-plugin + + ${project.build.directory}/testing + + + org.dspace + dspace-parent + ${project.version} + zip + testEnvironment + + + + + + setupUnitTestEnvironment + generate-test-resources + + unpack + + + + + + + + maven-surefire-plugin + + + + + ${agnostic.build.dir}/testing/dspace/ + + true + ${agnostic.build.dir}/testing/dspace/solr/ + + + + + + + + + org.dspace + dspace-api + test-jar + test + + + + + + + integration-test-environment + + false + + skipIntegrationTests + false + + + + + + + maven-dependency-plugin + + ${project.build.directory}/testing + + + org.dspace + dspace-parent + ${project.version} + zip + testEnvironment + + + + + + setupIntegrationTestEnvironment + pre-integration-test + + unpack + + + + + + + + maven-failsafe-plugin + + + + ${agnostic.build.dir}/testing/dspace/ + + true + ${agnostic.build.dir}/testing/dspace/solr/ + + + + + + + + + org.dspace + dspace-api + test-jar + test + + + + + + + + jakarta.servlet + jakarta.servlet-api + provided + + + org.dspace + dspace-services + + + + org.springframework.boot + spring-boot-starter + ${spring-boot.version} + + + org.springframework.boot + spring-boot-starter-logging + + + + + org.springframework.boot + spring-boot-starter-security + ${spring-boot.version} + + + + io.micrometer + micrometer-observation + + + + + org.springframework.security + spring-security-saml2-service-provider + ${spring-security.version} + + + + org.apache.velocity + velocity-engine-core + + + + + + + + junit + junit + test + + + org.dspace + dspace-api + test-jar + test + + + org.mockito + mockito-inline + test + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + shibboleth + Shibboleth Maven Repository + https://build.shibboleth.net/maven/releases/ + + + diff --git a/dspace-saml2/src/main/java/org/dspace/app/configuration/SamlWebSecurityConfiguration.java b/dspace-saml2/src/main/java/org/dspace/app/configuration/SamlWebSecurityConfiguration.java new file mode 100644 index 0000000000..2bfcd83767 --- /dev/null +++ b/dspace-saml2/src/main/java/org/dspace/app/configuration/SamlWebSecurityConfiguration.java @@ -0,0 +1,63 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.app.configuration; + +import org.dspace.saml2.DSpaceSamlAuthenticationFailureHandler; +import org.dspace.saml2.DSpaceSamlAuthenticationSuccessHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Web security configuration for SAML relying party endpoints. + *

+ * This establishes and manages security for the following endpoints: + *

    + *
  • /saml2/service-provider-metadata/{relyingPartyRegistrationId}
  • + *
  • /saml2/authenticate/{relyingPartyRegistrationId}
  • + *
  • /saml2/assertion-consumer/{relyingPartyRegistrationId}
  • + *
+ *

+ *

+ * This @Configuration class is automatically discovered by Spring Boot via a @ComponentScan + * on the org.dspace.app.configuration package. + *

+ * + * @author Ray Lee + */ +@EnableWebSecurity +@Configuration +@ComponentScan(basePackages = "org.dspace.saml2") +public class SamlWebSecurityConfiguration { + + /** + * Configure security on SAML relying party endpoints. + * + * @param http the HTTP security builder to configure + * @return the configured security filter chain + * @throws Exception + */ + @Bean + public SecurityFilterChain samlSecurityFilterChain(HttpSecurity http) throws Exception { + return http + .securityMatcher("/saml2/**") + // Initiate SAML login at /saml2/authenticate/{registrationId}. + .saml2Login(saml -> saml + // Accept SAML identity assertions from the IdP at /saml2/assertion-consumer/{registrationId}. + .loginProcessingUrl("/saml2/assertion-consumer/{registrationId}") + .successHandler(new DSpaceSamlAuthenticationSuccessHandler()) + .failureHandler(new DSpaceSamlAuthenticationFailureHandler())) + // Produce SAML relying party metadata at /saml2/service-provider-metadata/{registrationId}. + .saml2Metadata(Customizer.withDefaults()) + .build(); + } +} diff --git a/dspace-saml2/src/main/java/org/dspace/saml2/DSpaceRelyingPartyRegistrationRepository.java b/dspace-saml2/src/main/java/org/dspace/saml2/DSpaceRelyingPartyRegistrationRepository.java new file mode 100644 index 0000000000..32ad2b9537 --- /dev/null +++ b/dspace-saml2/src/main/java/org/dspace/saml2/DSpaceRelyingPartyRegistrationRepository.java @@ -0,0 +1,339 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.saml2; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.interfaces.RSAPrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.List; +import java.util.stream.Collectors; + +import com.google.common.io.CharStreams; +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationRuntimeException; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.security.saml2.core.Saml2X509Credential; +import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrations; +import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; +import org.springframework.stereotype.Component; + +/** + * A SAML RelyingPartyRegistrationRepository that builds and stores relying parties based on DSpace + * configuration. + * + * @author Ray Lee + */ +@Component +public class DSpaceRelyingPartyRegistrationRepository implements RelyingPartyRegistrationRepository { + private static final Logger logger = LoggerFactory.getLogger(DSpaceRelyingPartyRegistrationRepository.class); + + private RelyingPartyRegistrationRepository repository = null; + + public DSpaceRelyingPartyRegistrationRepository() { + reload(); + } + + /** + * Finds a relying party by ID. + * + * @see RelyingPartyRegistrationRepository#findByRegistrationId(String) + */ + @Override + public RelyingPartyRegistration findByRegistrationId(String registrationId) { + if (this.repository == null) { + return null; + } + + return this.repository.findByRegistrationId(registrationId); + } + + /** + * Reloads the stored SAML relying parties from DSpace configuration. + */ + public void reload() { + ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + + List registrations = configurationService.getChildren("saml-relying-party").stream() + .map(relyingPartyConfiguration -> buildRelyingPartyRegistration(relyingPartyConfiguration)) + .filter(registration -> registration != null) + .collect(Collectors.toList()); + + if (registrations.size() > 0) { + this.repository = new InMemoryRelyingPartyRegistrationRepository(registrations); + } else { + this.repository = null; + } + } + + /** + * Builds a RelyingPartyRegistration from a hierarchical configuration node. For example, + * in the following configuration, the node at saml-relying-party.example: + * + *

+     * saml-relying-party.example.asserting-party.metadata-uri = http://idp.example.com/samlp/metadata
+     * saml-relying-party.example.asserting-party.single-sign-on.binding = POST
+     * saml-relying-party.example.signing.credentials.0.private-key-location = file:///opt/dspace/secrets/rp-private.key
+     * saml-relying-party.example.signing.credentials.0.certificate-location = file:///opt/dspace/cert/rp-cert.crt
+     * 
+ * + * @param configuration the hierarchical configuration node + * @return a RelyingPartyRegistration configured according to the given configuration node + */ + private RelyingPartyRegistration buildRelyingPartyRegistration( + HierarchicalConfiguration configuration + ) { + String relyingPartyId = configuration.getRootElementName(); + + try { + HierarchicalConfiguration assertingPartyConfiguration = + getConfigurationAt(configuration, "asserting-party"); + + if (assertingPartyConfiguration == null) { + logger.warn("Couldn't find SAML asserting-party configuration for relying-party {}. " + + "Relying party will not be registered.", relyingPartyId); + + return null; + } + + String metadataUri = assertingPartyConfiguration.getString("metadata-uri"); + RelyingPartyRegistration.Builder registrationBuilder; + + if (metadataUri != null) { + registrationBuilder = RelyingPartyRegistrations + .fromMetadataLocation(metadataUri) + .registrationId(relyingPartyId); + } else { + registrationBuilder = RelyingPartyRegistration + .withRegistrationId(relyingPartyId); + } + + registrationBuilder.assertionConsumerServiceLocation("{baseUrl}/saml2/assertion-consumer/{registrationId}"); + + registrationBuilder.assertingPartyDetails(assertingParty -> { + String entityId = assertingPartyConfiguration.getString("entity-id"); + + if (entityId != null) { + assertingParty.entityId(entityId); + } + + HierarchicalConfiguration ssoConfiguration = + getConfigurationAt(assertingPartyConfiguration, "single-sign-on"); + + if (ssoConfiguration != null) { + String url = ssoConfiguration.getString("url"); + + if (url != null) { + assertingParty.singleSignOnServiceLocation(url); + } + + String binding = ssoConfiguration.getString("binding"); + + if (binding != null) { + assertingParty.singleSignOnServiceBinding(Saml2MessageBinding.valueOf(binding.toUpperCase())); + } + + Boolean shouldSignRequest = ssoConfiguration.getBoolean("sign-request", null); + + if (shouldSignRequest != null) { + assertingParty.wantAuthnRequestsSigned(shouldSignRequest); + } + } + + HierarchicalConfiguration sloConfiguration = + getConfigurationAt(assertingPartyConfiguration, "single-logout"); + + if (sloConfiguration != null) { + String url = sloConfiguration.getString("url"); + + if (url != null) { + assertingParty.singleLogoutServiceLocation(url); + } + + String binding = sloConfiguration.getString("binding"); + + if (binding != null) { + assertingParty.singleLogoutServiceBinding(Saml2MessageBinding.valueOf(binding.toUpperCase())); + } + + String responseUrl = sloConfiguration.getString("response-url"); + + if (responseUrl != null) { + assertingParty.singleLogoutServiceResponseLocation(responseUrl); + } + } + + List verificationCredentials = + assertingPartyConfiguration.childConfigurationsAt("verification.credentials").stream() + .map(credentialsConfiguration -> credentialsConfiguration.getString("certificate-location")) + .filter(certificateLocation -> certificateLocation != null) + .map(certificateLocation -> certificateFromUrl(certificateLocation)) + .filter(certificate -> certificate != null) + .map(certificate -> Saml2X509Credential.verification(certificate)) + .collect(Collectors.toList()); + + if (verificationCredentials.size() > 0) { + assertingParty.verificationX509Credentials(credentials -> { + credentials.clear(); + credentials.addAll(verificationCredentials); + }); + } + }); + + configuration.childConfigurationsAt("signing.credentials").stream() + .forEach(credentialsConfiguration -> { + String privateKeyLocation = credentialsConfiguration.getString("private-key-location"); + String certificateLocation = credentialsConfiguration.getString("certificate-location"); + + PrivateKey privateKey = privateKeyFromUrl(privateKeyLocation); + X509Certificate certificate = certificateFromUrl(certificateLocation); + + if (privateKey != null && certificate != null) { + registrationBuilder.signingX509Credentials(credentials -> + credentials.add(Saml2X509Credential.signing(privateKey, certificate))); + } + }); + + configuration.childConfigurationsAt("decryption.credentials").stream() + .forEach(credentialsConfiguration -> { + String privateKeyLocation = credentialsConfiguration.getString("private-key-location"); + String certificateLocation = credentialsConfiguration.getString("certificate-location"); + + PrivateKey privateKey = privateKeyFromUrl(privateKeyLocation); + X509Certificate certificate = certificateFromUrl(certificateLocation); + + if (privateKey != null && certificate != null) { + registrationBuilder.decryptionX509Credentials(credentials -> + credentials.add(Saml2X509Credential.decryption(privateKey, certificate))); + } + }); + + return registrationBuilder.build(); + } catch (Exception e) { + logger.error("Error building SAML relying party registration for id " + relyingPartyId, e); + + return null; + } + } + + private HierarchicalConfiguration getConfigurationAt( + HierarchicalConfiguration configuration, String key + ) { + try { + return configuration.configurationAt(key); + } catch (ConfigurationRuntimeException e) { + return null; + } + } + + /** + * Reads and decodes a private key from a given URL. The URL must point to a PEM file + * containing a PKCS8-encoded private key. + * + * @see Baeldung + * + * @param url The URL where the PRM file is located. This can be a file, http(s), or classpath URL. + * @return The private key. + */ + private PrivateKey privateKeyFromUrl(String url) { + if (url == null || url.length() == 0) { + return null; + } + + Resource resource = getResourceFromUrl(url); + + if (resource == null) { + logger.error("Resource not found at private key url: " + url); + + return null; + } + + if (!resource.exists()) { + logger.error("No resource exists at private key url: " + url); + + return null; + } + + try (Reader reader = new InputStreamReader(resource.getInputStream())) { + String key = CharStreams.toString(reader); + + String privateKeyPEM = key + .replace("-----BEGIN PRIVATE KEY-----", "") + .replaceAll(System.lineSeparator(), "") + .replace("-----END PRIVATE KEY-----", ""); + + byte[] encoded = Base64.getDecoder().decode(privateKeyPEM); + + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded); + + return (RSAPrivateKey) keyFactory.generatePrivate(keySpec); + } catch (Exception ex) { + logger.error("Error reading private key from " + url, ex); + + return null; + } + } + + /** + * Reads an X509 certificate from a given URL. + * + * @param url The URL where the certificate is located. This can be a file, http(s), or classpath URL. + * @return The X509 certificate. + */ + private X509Certificate certificateFromUrl(String url) { + if (url == null || url.length() == 0) { + return null; + } + + Resource resource = getResourceFromUrl(url); + + if (resource == null) { + logger.error("Resource not found at certificate url: " + url); + + return null; + } + + if (!resource.exists()) { + logger.error("No resource exists at certificate url: " + url); + + return null; + } + + try (InputStream is = resource.getInputStream()) { + return (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(is); + } catch (Exception ex) { + logger.error("Error reading certificate from " + url, ex); + + return null; + } + } + + private Resource getResourceFromUrl(String url) { + ResourceLoader resourceLoader = new DefaultResourceLoader(); + Resource resource = resourceLoader.getResource(url); + + return resource; + } +} diff --git a/dspace-saml2/src/main/java/org/dspace/saml2/DSpaceSamlAuthRequest.java b/dspace-saml2/src/main/java/org/dspace/saml2/DSpaceSamlAuthRequest.java new file mode 100644 index 0000000000..c5ed4bc09b --- /dev/null +++ b/dspace-saml2/src/main/java/org/dspace/saml2/DSpaceSamlAuthRequest.java @@ -0,0 +1,40 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.saml2; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import org.springframework.http.HttpMethod; + +/** + * A request wrapper for SAML authentication requests that are forwarded to the DSpace + * authentication endpoint from the assertion consumer endpoint of the SAML relying party. + *

+ * The assertion consumer may receive an assertion from the SAML asserting party via + * either GET or POST, depending on how the binding has been configured. This normalizes + * the method to GET when forwarding to the DSpace authentication endpoint, which has the + * advantage of bypassing (unnecessary) CORS protection on the DSpace authentication endpoint. + *

+ * + * @author Ray Lee + */ +public class DSpaceSamlAuthRequest extends HttpServletRequestWrapper { + public DSpaceSamlAuthRequest(HttpServletRequest request) { + super(request); + } + + /** + * Returns GET, regardless of the method of the wrapped requeset. + * + * @return "GET" + */ + @Override + public String getMethod() { + return HttpMethod.GET.name(); + } +} diff --git a/dspace-saml2/src/main/java/org/dspace/saml2/DSpaceSamlAuthenticationFailureHandler.java b/dspace-saml2/src/main/java/org/dspace/saml2/DSpaceSamlAuthenticationFailureHandler.java new file mode 100644 index 0000000000..83f8a4e810 --- /dev/null +++ b/dspace-saml2/src/main/java/org/dspace/saml2/DSpaceSamlAuthenticationFailureHandler.java @@ -0,0 +1,49 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.saml2; + +import java.io.IOException; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; + +/** + * Failure handler for SAML authentication. + *

+ * When a SAML authentication fails: + *

+ *
    + *
  • Log the error message and redirect to frontend.
  • + *
+ * + * @author Mark Cooper + */ +public class DSpaceSamlAuthenticationFailureHandler implements AuthenticationFailureHandler { + private static final Logger logger = LoggerFactory.getLogger(DSpaceSamlAuthenticationFailureHandler.class); + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + logger.error("SAML authentication failed: {}", exception.getMessage()); + + // For now we'll just redirect to the frontend login page. + // This will not help the user understand what went wrong, but the default error page is a 404 with DSpace, + // which is worse. + + ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + + response.sendRedirect(configurationService.getProperty("dspace.ui.url") + "/login"); + } +} diff --git a/dspace-saml2/src/main/java/org/dspace/saml2/DSpaceSamlAuthenticationSuccessHandler.java b/dspace-saml2/src/main/java/org/dspace/saml2/DSpaceSamlAuthenticationSuccessHandler.java new file mode 100644 index 0000000000..1dd3667f40 --- /dev/null +++ b/dspace-saml2/src/main/java/org/dspace/saml2/DSpaceSamlAuthenticationSuccessHandler.java @@ -0,0 +1,128 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.saml2; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; + +/** + * Handler for a successful SAML authentication. Note that this is not a successful DSpace login, + * only a successful login with a SAML identity provider. This handler initiates a DSpace login + * attempt, using the identity information received from the SAML IdP. + * + * @author Ray Lee + */ +public class DSpaceSamlAuthenticationSuccessHandler implements AuthenticationSuccessHandler { + private static final Logger logger = LoggerFactory.getLogger(DSpaceSamlAuthenticationSuccessHandler.class); + + protected ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + + /** + * When a SAML authentication succeeds: + *
    + *
  • Extract attributes from the assertion, and map them into request attributes using the mapping + * configured for the relying party that initiated the login.
  • + *
  • Forward the request to the DSpace SAML authentication endpoint.
  • + *
+ * @see + * AuthenticationSuccessHandler#onAuthenticationSuccess(HttpServletRequest, HttpServletResponse, Authentication) + */ + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + + Saml2AuthenticatedPrincipal principal = (Saml2AuthenticatedPrincipal) authentication.getPrincipal(); + String relyingPartyId = principal.getRelyingPartyRegistrationId(); + Map> samlAttributes = principal.getAttributes(); + + samlAttributes.forEach((attributeName, values) -> { + values.forEach(value -> { + logger.info("Incoming SAML attribute: {} = {}", attributeName, value); + }); + }); + + setRequestAttributesFromSamlAttributes(request, relyingPartyId, samlAttributes); + + request.setAttribute(getRelyingPartyIdAttributeName(), relyingPartyId); + request.setAttribute(getNameIdAttributeName(), principal.getName()); + + // Store all the attributes from the SAML assertion for debugging. + request.setAttribute("org.dspace.saml.ATTRIBUTES", samlAttributes); + + request.getRequestDispatcher("/api/authn/saml") + .forward(new DSpaceSamlAuthRequest(request), response); + } + + /** + * Extract attributes from a SAML identity assertion, and place the values into request + * attributes. The mapping of SAML attribute names to request attribute names is read from + * DSpace configuration. The mapping may be different for each SAML relying party. + * + * @param request The request in which the SAML assertion was received. Attributes from the + * assertion will be placed into attributes in this request. + * @param relyingPartyId The ID of the relying party that initiated the SAML login. + * @param samlAttributes The attributes from the SAML assertion. + */ + private void setRequestAttributesFromSamlAttributes( + HttpServletRequest request, String relyingPartyId, Map> samlAttributes) { + + String[] attributeMappings = configurationService.getArrayProperty( + "saml-relying-party." + relyingPartyId + ".attributes"); + + if (attributeMappings == null || attributeMappings.length == 0) { + logger.warn("No SAML attribute mappings found for relying party {}", relyingPartyId); + + return; + } + + Arrays.stream(attributeMappings) + .forEach(attributeMapping -> { + String[] parts = attributeMapping.split("=>"); + + if (parts.length != 2) { + logger.error("Unable to parse SAML attribute mapping for relying party {}: {}", + relyingPartyId, attributeMapping); + + return; + } + + String samlAttributeName = parts[0].trim(); + String requestAttributeName = parts[1].trim(); + + List values = samlAttributes.get(samlAttributeName); + + if (values != null) { + request.setAttribute(requestAttributeName, values); + } else { + logger.warn("No value found for SAML attribute {} in assertion", samlAttributeName); + } + }); + } + + private String getRelyingPartyIdAttributeName() { + return configurationService.getProperty("authentication-saml.attribute.relying-party-id", + "org.dspace.saml.RELYING_PARTY_ID"); + } + + private String getNameIdAttributeName() { + return configurationService.getProperty("authentication-saml.attribute.name-id", "org.dspace.saml.NAME_ID"); + } +} diff --git a/dspace-saml2/src/test/java/org/dspace/saml2/DSpaceRelyingPartyRegistrationRepositoryTest.java b/dspace-saml2/src/test/java/org/dspace/saml2/DSpaceRelyingPartyRegistrationRepositoryTest.java new file mode 100644 index 0000000000..df40c6e7f6 --- /dev/null +++ b/dspace-saml2/src/test/java/org/dspace/saml2/DSpaceRelyingPartyRegistrationRepositoryTest.java @@ -0,0 +1,435 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.saml2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.security.PrivateKey; +import java.security.cert.X509Certificate; + +import org.dspace.AbstractDSpaceTest; +import org.dspace.servicemanager.config.DSpaceConfigurationService; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.security.saml2.core.Saml2X509Credential; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration.AssertingPartyDetails; +import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; + +public class DSpaceRelyingPartyRegistrationRepositoryTest extends AbstractDSpaceTest { + private static ConfigurationService configurationService; + + @BeforeClass + public static void beforeAll() { + configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + } + + @Before + public void beforeEach() { + resetConfigurationService(); + } + + @Test + public void testConfigureAssertingPartyFromMetadata() throws Exception { + configurationService.setProperty( + "saml-relying-party.auth0.asserting-party.metadata-uri", "classpath:auth0-ap-metadata.xml"); + + DSpaceRelyingPartyRegistrationRepository repo = new DSpaceRelyingPartyRegistrationRepository(); + RelyingPartyRegistration registration = repo.findByRegistrationId("auth0"); + + assertNotNull(registration); + + AssertingPartyDetails assertingPartyDetails = registration.getAssertingPartyDetails(); + + assertNotNull(assertingPartyDetails); + assertEquals("urn:dev-vynkcnqhac3c0s10.us.auth0.com", assertingPartyDetails.getEntityId()); + + assertEquals("https://dev-vynkcnqhac3c0s10.us.auth0.com/samlp/Vn8jWX0iFHtepmXi7rjZa9h5M1kqXNWY/logout", + assertingPartyDetails.getSingleLogoutServiceLocation()); + + assertEquals(Saml2MessageBinding.REDIRECT, assertingPartyDetails.getSingleLogoutServiceBinding()); + + assertEquals("https://dev-vynkcnqhac3c0s10.us.auth0.com/samlp/Vn8jWX0iFHtepmXi7rjZa9h5M1kqXNWY/logout", + assertingPartyDetails.getSingleLogoutServiceResponseLocation()); + + assertEquals("https://dev-vynkcnqhac3c0s10.us.auth0.com/samlp/Vn8jWX0iFHtepmXi7rjZa9h5M1kqXNWY", + assertingPartyDetails.getSingleSignOnServiceLocation()); + + assertEquals(Saml2MessageBinding.REDIRECT, assertingPartyDetails.getSingleSignOnServiceBinding()); + assertFalse(assertingPartyDetails.getWantAuthnRequestsSigned()); + assertNotNull(assertingPartyDetails.getVerificationX509Credentials()); + assertEquals(1, assertingPartyDetails.getVerificationX509Credentials().size()); + + X509Certificate cert = assertingPartyDetails.getVerificationX509Credentials().stream() + .findFirst().get().getCertificate(); + + assertNotNull(cert); + assertEquals("SHA256withRSA", cert.getSigAlgName()); + assertEquals("CN=dev-vynkcnqhac3c0s10.us.auth0.com", cert.getSubjectDN().toString()); + } + + @Test + public void testConfigureAssertingPartyWithMetadataOverrides() throws Exception { + configurationService.setProperty( + "saml-relying-party.auth0.asserting-party.metadata-uri", "classpath:auth0-ap-metadata.xml"); + + configurationService.setProperty("saml-relying-party.auth0.asserting-party.entity-id", "my-entity-id"); + configurationService.setProperty("saml-relying-party.auth0.asserting-party.single-sign-on.url", "http://my.idp.org/sso"); + configurationService.setProperty("saml-relying-party.auth0.asserting-party.single-sign-on.binding", "post"); + configurationService.setProperty("saml-relying-party.auth0.asserting-party.single-sign-on.sign-request", true); + configurationService.setProperty("saml-relying-party.auth0.asserting-party.single-logout.url", "http://my.idp.org/slo"); + configurationService.setProperty("saml-relying-party.auth0.asserting-party.single-logout.binding", "post"); + configurationService.setProperty("saml-relying-party.auth0.asserting-party.single-logout.response-url", "http://my.idp.org/slo-response"); + + configurationService.setProperty( + "saml-relying-party.auth0.asserting-party.verification.credentials.0.certificate-location", + "classpath:auth0-ap-override-certificate.crt"); + + DSpaceRelyingPartyRegistrationRepository repo = new DSpaceRelyingPartyRegistrationRepository(); + RelyingPartyRegistration registration = repo.findByRegistrationId("auth0"); + + assertNotNull(registration); + + AssertingPartyDetails assertingPartyDetails = registration.getAssertingPartyDetails(); + + assertNotNull(assertingPartyDetails); + assertEquals("my-entity-id", assertingPartyDetails.getEntityId()); + assertEquals("http://my.idp.org/slo", assertingPartyDetails.getSingleLogoutServiceLocation()); + assertEquals(Saml2MessageBinding.POST, assertingPartyDetails.getSingleLogoutServiceBinding()); + assertEquals("http://my.idp.org/slo-response", assertingPartyDetails.getSingleLogoutServiceResponseLocation()); + assertEquals("http://my.idp.org/sso", assertingPartyDetails.getSingleSignOnServiceLocation()); + assertEquals(Saml2MessageBinding.POST, assertingPartyDetails.getSingleSignOnServiceBinding()); + assertTrue(assertingPartyDetails.getWantAuthnRequestsSigned()); + assertNotNull(assertingPartyDetails.getVerificationX509Credentials()); + assertEquals(1, assertingPartyDetails.getVerificationX509Credentials().size()); + + X509Certificate cert = assertingPartyDetails.getVerificationX509Credentials().stream() + .findFirst().get().getCertificate(); + + assertNotNull(cert); + assertEquals("SHA256withRSA", cert.getSigAlgName()); + + assertEquals( + "EMAILADDRESS=fhanik@pivotal.io, CN=simplesamlphp.cfapps.io, OU=IT, " + + "O=Saml Testing Server, L=Castle Rock, ST=CO, C=US", + cert.getSubjectDN().toString()); + } + + @Test + public void testConfigureAssertingPartyWithoutMetadata() throws Exception { + configurationService.setProperty("saml-relying-party.auth0.asserting-party.entity-id", "my-entity-id"); + configurationService.setProperty("saml-relying-party.auth0.asserting-party.single-sign-on.url", "http://my.idp.org/sso"); + configurationService.setProperty("saml-relying-party.auth0.asserting-party.single-sign-on.binding", "post"); + configurationService.setProperty("saml-relying-party.auth0.asserting-party.single-sign-on.sign-request", true); + configurationService.setProperty("saml-relying-party.auth0.asserting-party.single-logout.url", "http://my.idp.org/slo"); + configurationService.setProperty("saml-relying-party.auth0.asserting-party.single-logout.binding", "post"); + configurationService.setProperty("saml-relying-party.auth0.asserting-party.single-logout.response-url", "http://my.idp.org/slo-response"); + + configurationService.setProperty( + // CHECKSTYLE IGNORE LineLength FOR NEXT 1 LINES + "saml-relying-party.auth0.asserting-party.verification.credentials.0.certificate-location", + "classpath:auth0-ap-override-certificate.crt"); + + DSpaceRelyingPartyRegistrationRepository repo = new DSpaceRelyingPartyRegistrationRepository(); + RelyingPartyRegistration registration = repo.findByRegistrationId("auth0"); + + assertNotNull(registration); + + AssertingPartyDetails assertingPartyDetails = registration.getAssertingPartyDetails(); + + assertNotNull(assertingPartyDetails); + assertEquals("my-entity-id", assertingPartyDetails.getEntityId()); + assertEquals("http://my.idp.org/slo", assertingPartyDetails.getSingleLogoutServiceLocation()); + assertEquals(Saml2MessageBinding.POST, assertingPartyDetails.getSingleLogoutServiceBinding()); + assertEquals("http://my.idp.org/slo-response", assertingPartyDetails.getSingleLogoutServiceResponseLocation()); + assertEquals("http://my.idp.org/sso", assertingPartyDetails.getSingleSignOnServiceLocation()); + assertEquals(Saml2MessageBinding.POST, assertingPartyDetails.getSingleSignOnServiceBinding()); + assertTrue(assertingPartyDetails.getWantAuthnRequestsSigned()); + assertNotNull(assertingPartyDetails.getVerificationX509Credentials()); + assertEquals(1, assertingPartyDetails.getVerificationX509Credentials().size()); + + X509Certificate cert = assertingPartyDetails.getVerificationX509Credentials().stream() + .findFirst().get().getCertificate(); + + assertNotNull(cert); + assertEquals("SHA256withRSA", cert.getSigAlgName()); + + assertEquals( + "EMAILADDRESS=fhanik@pivotal.io, CN=simplesamlphp.cfapps.io, OU=IT, " + + "O=Saml Testing Server, L=Castle Rock, ST=CO, C=US", + cert.getSubjectDN().toString()); + } + + @Test + public void testConfigureRelyingPartySigningCredentials() throws Exception { + configurationService.setProperty( + "saml-relying-party.auth0.asserting-party.metadata-uri", "classpath:auth0-ap-metadata.xml"); + + configurationService.setProperty("saml-relying-party.auth0.signing.credentials.0.private-key-location", + "classpath:auth0-rp-private.key"); + + configurationService.setProperty("saml-relying-party.auth0.signing.credentials.0.certificate-location", + "classpath:auth0-rp-certificate.crt"); + + DSpaceRelyingPartyRegistrationRepository repo = new DSpaceRelyingPartyRegistrationRepository(); + RelyingPartyRegistration registration = repo.findByRegistrationId("auth0"); + + assertNotNull(registration); + assertNotNull(registration.getSigningX509Credentials()); + assertEquals(1, registration.getSigningX509Credentials().size()); + + Saml2X509Credential credential = registration.getSigningX509Credentials().stream() + .findFirst().get(); + + X509Certificate cert = credential.getCertificate(); + + assertNotNull(cert); + assertEquals("SHA256withRSA", cert.getSigAlgName()); + + assertEquals( + "CN=sp.spring.security.saml, OU=sp, O=Spring Security SAML, L=Vancouver, ST=Washington, C=US", + cert.getSubjectDN().toString()); + + PrivateKey key = credential.getPrivateKey(); + + assertNotNull(key); + assertEquals("RSA", key.getAlgorithm()); + assertEquals("PKCS#8", key.getFormat()); + } + + @Test + public void testConfigureRelyingPartyDecryptionCredentials() throws Exception { + configurationService.setProperty( + "saml-relying-party.auth0.asserting-party.metadata-uri", "classpath:auth0-ap-metadata.xml"); + + configurationService.setProperty("saml-relying-party.auth0.decryption.credentials.0.private-key-location", + "classpath:auth0-rp-private.key"); + + configurationService.setProperty("saml-relying-party.auth0.decryption.credentials.0.certificate-location", + "classpath:auth0-rp-certificate.crt"); + + DSpaceRelyingPartyRegistrationRepository repo = new DSpaceRelyingPartyRegistrationRepository(); + RelyingPartyRegistration registration = repo.findByRegistrationId("auth0"); + + assertNotNull(registration); + assertNotNull(registration.getDecryptionX509Credentials()); + assertEquals(1, registration.getDecryptionX509Credentials().size()); + + Saml2X509Credential credential = registration.getDecryptionX509Credentials().stream() + .findFirst().get(); + + X509Certificate cert = credential.getCertificate(); + + assertNotNull(cert); + assertEquals("SHA256withRSA", cert.getSigAlgName()); + + assertEquals( + "CN=sp.spring.security.saml, OU=sp, O=Spring Security SAML, L=Vancouver, ST=Washington, C=US", + cert.getSubjectDN().toString()); + + PrivateKey key = credential.getPrivateKey(); + + assertNotNull(key); + assertEquals("RSA", key.getAlgorithm()); + assertEquals("PKCS#8", key.getFormat()); + } + + @Test + public void testConfigureRelyingPartyInvalidBoolean() throws Exception { + configurationService.setProperty( + "saml-relying-party.auth0.asserting-party.metadata-uri", "classpath:auth0-ap-metadata.xml"); + + configurationService.setProperty("saml-relying-party.auth0.asserting-party.single-sign-on.sign-request", + "not a boolean"); + + DSpaceRelyingPartyRegistrationRepository repo = new DSpaceRelyingPartyRegistrationRepository(); + RelyingPartyRegistration registration = repo.findByRegistrationId("auth0"); + + assertNull(registration); + } + + @Test + public void testConfigureRelyingPartyCredentialsMissingKey() throws Exception { + configurationService.setProperty( + "saml-relying-party.auth0.asserting-party.metadata-uri", "classpath:auth0-ap-metadata.xml"); + + configurationService.setProperty("saml-relying-party.auth0.decryption.credentials.0.certificate-location", + "classpath:auth0-rp-certificate.crt"); + + DSpaceRelyingPartyRegistrationRepository repo = new DSpaceRelyingPartyRegistrationRepository(); + RelyingPartyRegistration registration = repo.findByRegistrationId("auth0"); + + assertNotNull(registration); + assertNotNull(registration.getDecryptionX509Credentials()); + assertEquals(0, registration.getDecryptionX509Credentials().size()); + } + + @Test + public void testConfigureRelyingPartyCredentialsKeyLocationNotFound() throws Exception { + configurationService.setProperty( + "saml-relying-party.auth0.asserting-party.metadata-uri", "classpath:auth0-ap-metadata.xml"); + + configurationService.setProperty("saml-relying-party.auth0.decryption.credentials.0.private-key-location", + "classpath:does-not-exist.key"); + + DSpaceRelyingPartyRegistrationRepository repo = new DSpaceRelyingPartyRegistrationRepository(); + RelyingPartyRegistration registration = repo.findByRegistrationId("auth0"); + + assertNotNull(registration); + assertNotNull(registration.getDecryptionX509Credentials()); + assertEquals(0, registration.getDecryptionX509Credentials().size()); + } + + @Test + public void testConfigureRelyingPartyCredentialsKeyFileInvalid() throws Exception { + configurationService.setProperty( + "saml-relying-party.auth0.asserting-party.metadata-uri", "classpath:auth0-ap-metadata.xml"); + + configurationService.setProperty("saml-relying-party.auth0.decryption.credentials.0.private-key-location", + "classpath:invalid.key"); + + DSpaceRelyingPartyRegistrationRepository repo = new DSpaceRelyingPartyRegistrationRepository(); + RelyingPartyRegistration registration = repo.findByRegistrationId("auth0"); + + assertNotNull(registration); + assertNotNull(registration.getDecryptionX509Credentials()); + assertEquals(0, registration.getDecryptionX509Credentials().size()); + } + + @Test + public void testConfigureRelyingPartyCredentialsMissingCertificate() throws Exception { + configurationService.setProperty( + "saml-relying-party.auth0.asserting-party.metadata-uri", "classpath:auth0-ap-metadata.xml"); + + configurationService.setProperty("saml-relying-party.auth0.decryption.credentials.0.private-key-location", + "classpath:auth0-rp-private.key"); + + DSpaceRelyingPartyRegistrationRepository repo = new DSpaceRelyingPartyRegistrationRepository(); + RelyingPartyRegistration registration = repo.findByRegistrationId("auth0"); + + assertNotNull(registration); + assertNotNull(registration.getDecryptionX509Credentials()); + assertEquals(0, registration.getDecryptionX509Credentials().size()); + } + + @Test + public void testConfigureRelyingPartyCredentialsCertificateLocationNotFound() throws Exception { + configurationService.setProperty( + "saml-relying-party.auth0.asserting-party.metadata-uri", "classpath:auth0-ap-metadata.xml"); + + configurationService.setProperty("saml-relying-party.auth0.decryption.credentials.0.certificate-location", + "classpath:does-not-exist.crt"); + + DSpaceRelyingPartyRegistrationRepository repo = new DSpaceRelyingPartyRegistrationRepository(); + RelyingPartyRegistration registration = repo.findByRegistrationId("auth0"); + + assertNotNull(registration); + assertNotNull(registration.getDecryptionX509Credentials()); + assertEquals(0, registration.getDecryptionX509Credentials().size()); + } + + @Test + public void testConfigureRelyingPartyCredentialsCertificateFileInvalid() throws Exception { + configurationService.setProperty( + "saml-relying-party.auth0.asserting-party.metadata-uri", "classpath:auth0-ap-metadata.xml"); + + configurationService.setProperty("saml-relying-party.auth0.decryption.credentials.0.certificate-location", + "classpath:invalid.crt"); + + DSpaceRelyingPartyRegistrationRepository repo = new DSpaceRelyingPartyRegistrationRepository(); + RelyingPartyRegistration registration = repo.findByRegistrationId("auth0"); + + assertNotNull(registration); + assertNotNull(registration.getDecryptionX509Credentials()); + assertEquals(0, registration.getDecryptionX509Credentials().size()); + } + + @Test + public void testConfigureRelyingPartyMissingAssertingParty() throws Exception { + configurationService.setProperty("saml-relying-party.auth0.signing.credentials.0.private-key-location", + "classpath:auth0-rp-private.key"); + + configurationService.setProperty("saml-relying-party.auth0.signing.credentials.0.certificate-location", + "classpath:auth0-rp-certificate.crt"); + + DSpaceRelyingPartyRegistrationRepository repo = new DSpaceRelyingPartyRegistrationRepository(); + RelyingPartyRegistration registration = repo.findByRegistrationId("auth0"); + + assertNull(registration); + } + + @Test + public void testConfigureRelyingPartyCredentialsFromFileUrls() throws Exception { + configurationService.setProperty( + "saml-relying-party.auth0.asserting-party.metadata-uri", "classpath:auth0-ap-metadata.xml"); + + configurationService.setProperty("saml-relying-party.auth0.signing.credentials.0.private-key-location", + "file://" + new ClassPathResource("auth0-rp-private.key").getFile().getAbsolutePath()); + + configurationService.setProperty("saml-relying-party.auth0.signing.credentials.0.certificate-location", + "file://" + new ClassPathResource("auth0-rp-certificate.crt").getFile().getAbsolutePath()); + + DSpaceRelyingPartyRegistrationRepository repo = new DSpaceRelyingPartyRegistrationRepository(); + RelyingPartyRegistration registration = repo.findByRegistrationId("auth0"); + + assertNotNull(registration); + assertNotNull(registration.getSigningX509Credentials()); + assertEquals(1, registration.getSigningX509Credentials().size()); + + Saml2X509Credential credential = registration.getSigningX509Credentials().stream() + .findFirst().get(); + + X509Certificate cert = credential.getCertificate(); + + assertNotNull(cert); + assertEquals("SHA256withRSA", cert.getSigAlgName()); + + assertEquals( + "CN=sp.spring.security.saml, OU=sp, O=Spring Security SAML, L=Vancouver, ST=Washington, C=US", + cert.getSubjectDN().toString()); + + PrivateKey key = credential.getPrivateKey(); + + assertNotNull(key); + assertEquals("RSA", key.getAlgorithm()); + assertEquals("PKCS#8", key.getFormat()); + } + + @Test + public void testConfigureRelyingPartyCredentialsFromMalformedUrls() throws Exception { + configurationService.setProperty( + "saml-relying-party.auth0.asserting-party.metadata-uri", "classpath:auth0-ap-metadata.xml"); + + configurationService.setProperty("saml-relying-party.auth0.signing.credentials.0.private-key-location", + "xyz://auth0-rp-p rivate.key"); //oops + + configurationService.setProperty("saml-relying-party.auth0.signing.credentials.0.certificate-location", + "classpath:auth0-rp-certificate.crt"); + + DSpaceRelyingPartyRegistrationRepository repo = new DSpaceRelyingPartyRegistrationRepository(); + RelyingPartyRegistration registration = repo.findByRegistrationId("auth0"); + + assertNotNull(registration); + assertNotNull(registration.getSigningX509Credentials()); + assertEquals(0, registration.getSigningX509Credentials().size()); + } + + private void resetConfigurationService() { + ((DSpaceConfigurationService) configurationService).clear(); + + configurationService.reloadConfig(); + } +} diff --git a/dspace-saml2/src/test/java/org/dspace/saml2/DSpaceSamlAuthRequestTest.java b/dspace-saml2/src/test/java/org/dspace/saml2/DSpaceSamlAuthRequestTest.java new file mode 100644 index 0000000000..cb72200be7 --- /dev/null +++ b/dspace-saml2/src/test/java/org/dspace/saml2/DSpaceSamlAuthRequestTest.java @@ -0,0 +1,42 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +package org.dspace.saml2; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; +import org.springframework.http.HttpMethod; +import org.springframework.mock.web.MockHttpServletRequest; + +/** + * @author Ray Lee + */ +public class DSpaceSamlAuthRequestTest { + @Test + public void testWrapPostRequest() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + + request.setMethod(HttpMethod.POST.name()); + + DSpaceSamlAuthRequest samlAuthRequest = new DSpaceSamlAuthRequest(request); + + assertEquals(HttpMethod.GET.name(), samlAuthRequest.getMethod()); + } + + @Test + public void testWrapGetRequest() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + + request.setMethod(HttpMethod.GET.name()); + + DSpaceSamlAuthRequest samlAuthRequest = new DSpaceSamlAuthRequest(request); + + assertEquals(HttpMethod.GET.name(), samlAuthRequest.getMethod()); + } +} diff --git a/dspace-saml2/src/test/java/org/dspace/saml2/DSpaceSamlAuthenticationSuccessHandlerTest.java b/dspace-saml2/src/test/java/org/dspace/saml2/DSpaceSamlAuthenticationSuccessHandlerTest.java new file mode 100644 index 0000000000..6a5fe96c99 --- /dev/null +++ b/dspace-saml2/src/test/java/org/dspace/saml2/DSpaceSamlAuthenticationSuccessHandlerTest.java @@ -0,0 +1,182 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.saml2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import jakarta.servlet.RequestDispatcher; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.dspace.AbstractDSpaceTest; +import org.dspace.servicemanager.config.DSpaceConfigurationService; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.Authentication; +import org.springframework.security.saml2.provider.service.authentication.DefaultSaml2AuthenticatedPrincipal; +import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication; + +public class DSpaceSamlAuthenticationSuccessHandlerTest extends AbstractDSpaceTest { + + private static ConfigurationService configurationService; + + private HttpServletRequest request; + private RequestDispatcher requestDispatcher; + private HttpServletResponse response; + + @BeforeClass + public static void beforeAll() { + configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + } + + @Before + public void beforeEach() { + resetConfigurationService(); + + request = Mockito.spy(new MockHttpServletRequest()); + requestDispatcher = Mockito.mock(RequestDispatcher.class); + + when(request.getRequestDispatcher("/api/authn/saml")) + .thenReturn(requestDispatcher); + + response = Mockito.mock(HttpServletResponse.class); + } + + @Test + public void testRequestIsForwardedToAuthEndpoint() throws Exception { + Authentication auth = createAuthentication("rp-id", "name-id", Collections.emptyMap()); + DSpaceSamlAuthenticationSuccessHandler handler = new DSpaceSamlAuthenticationSuccessHandler(); + + handler.onAuthenticationSuccess(request, response, auth); + + verify(requestDispatcher).forward(any(DSpaceSamlAuthRequest.class), any(HttpServletResponse.class)); + } + + @Test + public void testStandardRequestAttributesAreSet() throws Exception { + Map> samlAttributes = Map.ofEntries( + Map.entry("saml-attr-name-1", List.of("attr-value-1")), + Map.entry("saml-attr-name-2", List.of("attr-value-2")) + ); + + Authentication auth = createAuthentication("rp-id", "user-name-id", samlAttributes); + DSpaceSamlAuthenticationSuccessHandler handler = new DSpaceSamlAuthenticationSuccessHandler(); + + handler.onAuthenticationSuccess(request, response, auth); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpServletRequest.class); + + verify(requestDispatcher).forward(requestCaptor.capture(), any(HttpServletResponse.class)); + + HttpServletRequest forwardedRequest = requestCaptor.getValue(); + + assertEquals("rp-id", forwardedRequest.getAttribute("org.dspace.saml.RELYING_PARTY_ID")); + assertEquals("user-name-id", forwardedRequest.getAttribute("org.dspace.saml.NAME_ID")); + assertEquals(samlAttributes, forwardedRequest.getAttribute("org.dspace.saml.ATTRIBUTES")); + } + + @Test + public void testSamlAttributesAreMappedToRequestAttributes() throws Exception { + Map> samlAttributes = Map.ofEntries( + Map.entry("saml-given-name", List.of("Diana")), + Map.entry("saml-family-name", List.of("Prince")), + Map.entry("saml-email-address", List.of("wonder@justiceleague.org", "diana@prince.com")), + Map.entry("not-mapped", List.of("unmapped value")) + ); + + configurationService.setProperty("saml-relying-party.rp-id.attributes", + "saml-given-name => org.dspace.saml.GIVEN_NAME," + + "saml-family-name => org.dspace.saml.SURNAME," + + "saml-email-address => org.dspace.saml.EMAIL," + + "saml-foo => org.dspace.saml.FOO" + ); + + Authentication auth = createAuthentication("rp-id", "user-name-id", samlAttributes); + DSpaceSamlAuthenticationSuccessHandler handler = new DSpaceSamlAuthenticationSuccessHandler(); + + handler.onAuthenticationSuccess(request, response, auth); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpServletRequest.class); + + verify(requestDispatcher).forward(requestCaptor.capture(), any(HttpServletResponse.class)); + + HttpServletRequest forwardedRequest = requestCaptor.getValue(); + + assertEquals(List.of("Diana"), forwardedRequest.getAttribute("org.dspace.saml.GIVEN_NAME")); + assertEquals(List.of("Prince"), forwardedRequest.getAttribute("org.dspace.saml.SURNAME")); + + assertEquals(List.of("wonder@justiceleague.org", "diana@prince.com"), + forwardedRequest.getAttribute("org.dspace.saml.EMAIL")); + + assertNull(forwardedRequest.getAttribute("org.dspace.saml.FOO")); + } + + @Test + public void testMisconfiguredAttributeMappingIsIgnored() throws Exception { + Map> samlAttributes = Map.ofEntries( + Map.entry("saml-given-name", List.of("Diana")), + Map.entry("saml-family-name", List.of("Prince")), + Map.entry("saml-email-address", List.of("wonder@justiceleague.org", "diana@prince.com")), + Map.entry("not-mapped", List.of("unmapped value")) + ); + + configurationService.setProperty("saml-relying-party.rp-id.attributes", + "saml-given-name => org.dspace.saml.GIVEN_NAME," + + "saml-family-name > org.dspace.saml.SURNAME," + // oops + "saml-email-address => org.dspace.saml.EMAIL" + ); + + Authentication auth = createAuthentication("rp-id", "user-name-id", samlAttributes); + DSpaceSamlAuthenticationSuccessHandler handler = new DSpaceSamlAuthenticationSuccessHandler(); + + handler.onAuthenticationSuccess(request, response, auth); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpServletRequest.class); + + verify(requestDispatcher).forward(requestCaptor.capture(), any(HttpServletResponse.class)); + + HttpServletRequest forwardedRequest = requestCaptor.getValue(); + + assertEquals(List.of("Diana"), forwardedRequest.getAttribute("org.dspace.saml.GIVEN_NAME")); + assertNull(forwardedRequest.getAttribute("org.dspace.saml.SURNAME")); + + assertEquals(List.of("wonder@justiceleague.org", "diana@prince.com"), + forwardedRequest.getAttribute("org.dspace.saml.EMAIL")); + } + + private void resetConfigurationService() { + ((DSpaceConfigurationService) configurationService).clear(); + + configurationService.reloadConfig(); + } + + private Authentication createAuthentication( + String relyingPartyRegistrationId, String name, Map> attributes + ) { + DefaultSaml2AuthenticatedPrincipal principal = new DefaultSaml2AuthenticatedPrincipal(name, attributes); + + principal.setRelyingPartyRegistrationId(relyingPartyRegistrationId); + + Authentication auth = new Saml2Authentication(principal, "", Collections.emptyList()); + + return auth; + } +} diff --git a/dspace-saml2/src/test/resources/auth0-ap-metadata.xml b/dspace-saml2/src/test/resources/auth0-ap-metadata.xml new file mode 100644 index 0000000000..9bae62fc4c --- /dev/null +++ b/dspace-saml2/src/test/resources/auth0-ap-metadata.xml @@ -0,0 +1,23 @@ + + + + + + MIIDHTCCAgWgAwIBAgIJLbX385VIDN0EMA0GCSqGSIb3DQEBCwUAMCwxKjAoBgNVBAMTIWRldi12eW5rY25xaGFjM2MwczEwLnVzLmF1dGgwLmNvbTAeFw0yMzA4MjUwMjUxMjhaFw0zNzA1MDMwMjUxMjhaMCwxKjAoBgNVBAMTIWRldi12eW5rY25xaGFjM2MwczEwLnVzLmF1dGgwLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALSK8Ue5flbm3iOphvgWTL5MZHM/BmDURrmAUN3b+Ts6x99o5/klIhgyKeRosb4N29vCM4PVmxvcp2RXYrBlru2KzueY98hvF+NlkPV/tfNkRBMCOempBw0LEJzN6g0GRM4j6LgXU6hYxtkHB7Sze8KzRqVknYNEpma3hw5H4ZzVuZ9q/joYWmCLWWaEv+GhikIlBTJhg1cfIh0JP2kuU4zdWmzS/RKA2IoMxfCNzPD4Qp9J0LxL4ovyj5eNBTIFCrkB+v9UWbFifDI/PwMWdhTUuR3O7k4SFGdBxdQk3cUVYuSN7wZbrgHgCvJ4IEJODRFPgecpPKaOTuhYBHs8ylkCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUD0/pQ2xl6YSDg00WD1U3ZqRDFdowDgYDVR0PAQH/BAQDAgKEMA0GCSqGSIb3DQEBCwUAA4IBAQCCKkxVSQUI38CfdlNoXQvOqZHs+KdlAtTjbqoVYIWYVKV6bcHABnwkzxnFuYUnE454c9Q5JxL+xJ+BcBHzk2Mx5xTihq7D6QmONpJQxM09l7sormRus51DGu+8rTeODWlxAMICsbELLO+l+id8HRPbt9H0M/s/8rY81Ys6W8WUD7xitTKhblzOO9Ei4m+IQDh7pBqY9vg0of6ezJ+P9MCyd5+dNQaiHBUVA0SadtE3ZALzChHd+hLkqpUzntqdNwdEWHZjo18ov0tquQEcGxttbfVK9IFuX5uDOzOaTE+kYEi6le+3D+M1HZHxdDSCeGbAsS0YKp+yHkj6cFU+sQiJ + + + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + urn:oasis:names:tc:SAML:2.0:nameid-format:persistent + urn:oasis:names:tc:SAML:2.0:nameid-format:transient + + + + + + + + + \ No newline at end of file diff --git a/dspace-saml2/src/test/resources/auth0-ap-override-certificate.crt b/dspace-saml2/src/test/resources/auth0-ap-override-certificate.crt new file mode 100644 index 0000000000..c04a9c1602 --- /dev/null +++ b/dspace-saml2/src/test/resources/auth0-ap-override-certificate.crt @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIEEzCCAvugAwIBAgIJAIc1qzLrv+5nMA0GCSqGSIb3DQEBCwUAMIGfMQswCQYD +VQQGEwJVUzELMAkGA1UECAwCQ08xFDASBgNVBAcMC0Nhc3RsZSBSb2NrMRwwGgYD +VQQKDBNTYW1sIFRlc3RpbmcgU2VydmVyMQswCQYDVQQLDAJJVDEgMB4GA1UEAwwX +c2ltcGxlc2FtbHBocC5jZmFwcHMuaW8xIDAeBgkqhkiG9w0BCQEWEWZoYW5pa0Bw +aXZvdGFsLmlvMB4XDTE1MDIyMzIyNDUwM1oXDTI1MDIyMjIyNDUwM1owgZ8xCzAJ +BgNVBAYTAlVTMQswCQYDVQQIDAJDTzEUMBIGA1UEBwwLQ2FzdGxlIFJvY2sxHDAa +BgNVBAoME1NhbWwgVGVzdGluZyBTZXJ2ZXIxCzAJBgNVBAsMAklUMSAwHgYDVQQD +DBdzaW1wbGVzYW1scGhwLmNmYXBwcy5pbzEgMB4GCSqGSIb3DQEJARYRZmhhbmlr +QHBpdm90YWwuaW8wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC4cn62 +E1xLqpN34PmbrKBbkOXFjzWgJ9b+pXuaRft6A339uuIQeoeH5qeSKRVTl32L0gdz +2ZivLwZXW+cqvftVW1tvEHvzJFyxeTW3fCUeCQsebLnA2qRa07RkxTo6Nf244mWW +RDodcoHEfDUSbxfTZ6IExSojSIU2RnD6WllYWFdD1GFpBJOmQB8rAc8wJIBdHFdQ +nX8Ttl7hZ6rtgqEYMzYVMuJ2F2r1HSU1zSAvwpdYP6rRGFRJEfdA9mm3WKfNLSc5 +cljz0X/TXy0vVlAV95l9qcfFzPmrkNIst9FZSwpvB49LyAVke04FQPPwLgVH4gph +iJH3jvZ7I+J5lS8VAgMBAAGjUDBOMB0GA1UdDgQWBBTTyP6Cc5HlBJ5+ucVCwGc5 +ogKNGzAfBgNVHSMEGDAWgBTTyP6Cc5HlBJ5+ucVCwGc5ogKNGzAMBgNVHRMEBTAD +AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAvMS4EQeP/ipV4jOG5lO6/tYCb/iJeAduO +nRhkJk0DbX329lDLZhTTL/x/w/9muCVcvLrzEp6PN+VWfw5E5FWtZN0yhGtP9R+v +ZnrV+oc2zGD+no1/ySFOe3EiJCO5dehxKjYEmBRv5sU/LZFKZpozKN/BMEa6CqLu +xbzb7ykxVr7EVFXwltPxzE9TmL9OACNNyF5eJHWMRMllarUvkcXlh4pux4ks9e6z +V9DQBy2zds9f1I3qxg0eX6JnGrXi/ZiCT+lJgVe3ZFXiejiLAiKB04sXW3ti0LW3 +lx13Y1YlQ4/tlpgTgfIJxKV6nyPiLoK0nywbMd+vpAirDt2Oc+hk +-----END CERTIFICATE----- \ No newline at end of file diff --git a/dspace-saml2/src/test/resources/auth0-rp-certificate.crt b/dspace-saml2/src/test/resources/auth0-rp-certificate.crt new file mode 100644 index 0000000000..e9cf8e3bcc --- /dev/null +++ b/dspace-saml2/src/test/resources/auth0-rp-certificate.crt @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE----- +MIICgTCCAeoCCQCuVzyqFgMSyDANBgkqhkiG9w0BAQsFADCBhDELMAkGA1UEBhMC +VVMxEzARBgNVBAgMCldhc2hpbmd0b24xEjAQBgNVBAcMCVZhbmNvdXZlcjEdMBsG +A1UECgwUU3ByaW5nIFNlY3VyaXR5IFNBTUwxCzAJBgNVBAsMAnNwMSAwHgYDVQQD +DBdzcC5zcHJpbmcuc2VjdXJpdHkuc2FtbDAeFw0xODA1MTQxNDMwNDRaFw0yODA1 +MTExNDMwNDRaMIGEMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2FzaGluZ3RvbjES +MBAGA1UEBwwJVmFuY291dmVyMR0wGwYDVQQKDBRTcHJpbmcgU2VjdXJpdHkgU0FN +TDELMAkGA1UECwwCc3AxIDAeBgNVBAMMF3NwLnNwcmluZy5zZWN1cml0eS5zYW1s +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDRu7/EI0BlNzMEBFVAcbx+lLos +vzIWU+01dGTY8gBdhMQNYKZ92lMceo2CuVJ66cUURPym3i7nGGzoSnAxAre+0YIM ++U0razrWtAUE735bkcqELZkOTZLelaoOztmWqRbe5OuEmpewH7cx+kNgcVjdctOG +y3Q6x+I4qakY/9qhBQIDAQABMA0GCSqGSIb3DQEBCwUAA4GBAAeViTvHOyQopWEi +XOfI2Z9eukwrSknDwq/zscR0YxwwqDBMt/QdAODfSwAfnciiYLkmEjlozWRtOeN+ +qK7UFgP1bRl5qksrYX5S0z2iGJh0GvonLUt3e20Ssfl5tTEDDnAEUMLfBkyaxEHD +RZ/nbTJ7VTeZOSyRoVn5XHhpuJ0B +-----END CERTIFICATE----- \ No newline at end of file diff --git a/dspace-saml2/src/test/resources/auth0-rp-private.key b/dspace-saml2/src/test/resources/auth0-rp-private.key new file mode 100644 index 0000000000..c9db80095a --- /dev/null +++ b/dspace-saml2/src/test/resources/auth0-rp-private.key @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBANG7v8QjQGU3MwQE +VUBxvH6Uuiy/MhZT7TV0ZNjyAF2ExA1gpn3aUxx6jYK5UnrpxRRE/KbeLucYbOhK +cDECt77Rggz5TStrOta0BQTvfluRyoQtmQ5Nkt6Vqg7O2ZapFt7k64Sal7AftzH6 +Q2BxWN1y04bLdDrH4jipqRj/2qEFAgMBAAECgYEAj4ExY1jjdN3iEDuOwXuRB+Nn +x7pC4TgntE2huzdKvLJdGvIouTArce8A6JM5NlTBvm69mMepvAHgcsiMH1zGr5J5 +wJz23mGOyhM1veON41/DJTVG+cxq4soUZhdYy3bpOuXGMAaJ8QLMbQQoivllNihd +vwH0rNSK8LTYWWPZYIECQQDxct+TFX1VsQ1eo41K0T4fu2rWUaxlvjUGhK6HxTmY +8OMJptunGRJL1CUjIb45Uz7SP8TPz5FwhXWsLfS182kRAkEA3l+Qd9C9gdpUh1uX +oPSNIxn5hFUrSTW1EwP9QH9vhwb5Vr8Jrd5ei678WYDLjUcx648RjkjhU9jSMzIx +EGvYtQJBAMm/i9NR7IVyyNIgZUpz5q4LI21rl1r4gUQuD8vA36zM81i4ROeuCly0 +KkfdxR4PUfnKcQCX11YnHjk9uTFj75ECQEFY/gBnxDjzqyF35hAzrYIiMPQVfznt +YX/sDTE2AdVBVGaMj1Cb51bPHnNC6Q5kXKQnj/YrLqRQND09Q7ParX0CQQC5NxZr +9jKqhHj8yQD6PlXTsY4Occ7DH6/IoDenfdEVD5qlet0zmd50HatN2Jiqm5ubN7CM +INrtuLp4YHbgk1mi +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/dspace-saml2/src/test/resources/invalid.crt b/dspace-saml2/src/test/resources/invalid.crt new file mode 100644 index 0000000000..4d956b876b --- /dev/null +++ b/dspace-saml2/src/test/resources/invalid.crt @@ -0,0 +1 @@ +This is not an X500 certificate. \ No newline at end of file diff --git a/dspace-saml2/src/test/resources/invalid.key b/dspace-saml2/src/test/resources/invalid.key new file mode 100644 index 0000000000..05c6302f97 --- /dev/null +++ b/dspace-saml2/src/test/resources/invalid.key @@ -0,0 +1 @@ +This is not a private key. \ No newline at end of file diff --git a/dspace-server-webapp/pom.xml b/dspace-server-webapp/pom.xml index 2e8f2cafc0..ada9b22adb 100644 --- a/dspace-server-webapp/pom.xml +++ b/dspace-server-webapp/pom.xml @@ -31,7 +31,7 @@ org.codehaus.mojo properties-maven-plugin - 1.1.0 + 1.2.1 initialize @@ -293,14 +293,6 @@ spring-expression ${spring.version} - - - @@ -338,11 +330,23 @@ spring-boot-starter-aop ${spring-boot.version} - + org.springframework.boot spring-boot-starter-actuator ${spring-boot.version} + + + + io.micrometer + micrometer-observation + + + + io.micrometer + micrometer-commons + + @@ -419,7 +423,7 @@ org.webjars.npm json-editor__json-editor - 2.6.1 + 2.15.1 + io.micrometer micrometer-observation @@ -456,6 +460,11 @@ org.springframework.boot spring-boot-starter-logging + + + org.springframework + spring-jcl + @@ -499,6 +508,10 @@ org.dspace dspace-oai + + org.dspace + dspace-saml2 + org.dspace dspace-rdf @@ -543,7 +556,7 @@ net.minidev json-smart - 2.5.0 + 2.5.2 @@ -581,7 +594,7 @@ org.apache.httpcomponents.client5 httpclient5 - 5.3.1 + 5.4.2 test @@ -593,6 +606,13 @@ com.jayway.jsonpath json-path + + + + net.minidev + json-smart + + com.jayway.jsonpath diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/AuthenticationRestController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/AuthenticationRestController.java index 070f3d8a18..63ac50b6ea 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/AuthenticationRestController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/AuthenticationRestController.java @@ -220,7 +220,7 @@ public class AuthenticationRestController implements InitializingBean { * @return ResponseEntity */ @RequestMapping(value = "/login", method = { RequestMethod.GET, RequestMethod.PUT, RequestMethod.PATCH, - RequestMethod.DELETE }) + RequestMethod.DELETE }) public ResponseEntity login() { return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body("Only POST is allowed for login requests."); } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamRestController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamRestController.java index db9d26a5f6..11b048e23e 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamRestController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamRestController.java @@ -135,11 +135,16 @@ public class BitstreamRestController { long filesize = bit.getSizeBytes(); Boolean citationEnabledForBitstream = citationDocumentService.isCitationEnabledForBitstream(bit, context); + var bitstreamResource = + new org.dspace.app.rest.utils.BitstreamResource(name, uuid, + currentUser != null ? currentUser.getID() : null, + context.getSpecialGroupUuids(), citationEnabledForBitstream); + HttpHeadersInitializer httpHeadersInitializer = new HttpHeadersInitializer() .withBufferSize(BUFFER_SIZE) .withFileName(name) - .withChecksum(bit.getChecksum()) - .withLength(bit.getSizeBytes()) + .withChecksum(bitstreamResource.getChecksum()) + .withLength(bitstreamResource.contentLength()) .withMimetype(mimetype) .with(request) .with(response); @@ -157,11 +162,6 @@ public class BitstreamRestController { httpHeadersInitializer.withDisposition(HttpHeadersInitializer.CONTENT_DISPOSITION_ATTACHMENT); } - org.dspace.app.rest.utils.BitstreamResource bitstreamResource = - new org.dspace.app.rest.utils.BitstreamResource(name, uuid, - currentUser != null ? currentUser.getID() : null, - context.getSpecialGroupUuids(), citationEnabledForBitstream); - //We have all the data we need, close the connection to the database so that it doesn't stay open during //download/streaming context.complete(); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/ItemOwningCollectionUpdateRestController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/ItemOwningCollectionUpdateRestController.java index eec5b15825..db238e1a5c 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/ItemOwningCollectionUpdateRestController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/ItemOwningCollectionUpdateRestController.java @@ -43,7 +43,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; /** - * This controller will handle all the incoming calls on the api/code/items/{uuid}/owningCollection endpoint + * This controller will handle all the incoming calls on the api/core/items/{uuid}/owningCollection endpoint * where the uuid corresponds to the item of which you want to edit the owning collection. */ @RestController diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/OpenSearchController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/OpenSearchController.java index baf45c14b6..39040b7b3c 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/OpenSearchController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/OpenSearchController.java @@ -7,11 +7,12 @@ */ package org.dspace.app.rest; +import static org.dspace.app.rest.utils.HttpHeadersInitializer.CONTENT_DISPOSITION; +import static org.dspace.app.rest.utils.HttpHeadersInitializer.CONTENT_DISPOSITION_INLINE; + import java.io.IOException; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerException; import javax.xml.transform.TransformerFactory; @@ -21,10 +22,12 @@ import javax.xml.transform.stream.StreamResult; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.Logger; +import org.dspace.app.rest.parameter.SearchFilter; import org.dspace.app.rest.utils.ContextUtil; +import org.dspace.app.rest.utils.RestDiscoverQueryBuilder; import org.dspace.app.rest.utils.ScopeResolver; -import org.dspace.app.util.SyndicationFeed; import org.dspace.app.util.factory.UtilServiceFactory; import org.dspace.app.util.service.OpenSearchService; import org.dspace.authorize.factory.AuthorizeServiceFactory; @@ -49,6 +52,9 @@ import org.dspace.discovery.configuration.DiscoverySortConfiguration; import org.dspace.discovery.configuration.DiscoverySortFieldConfiguration; import org.dspace.discovery.indexobject.IndexableItem; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -86,22 +92,28 @@ public class OpenSearchController { @Autowired private ScopeResolver scopeResolver; + @Autowired + private RestDiscoverQueryBuilder restDiscoverQueryBuilder; + /** * This method provides the OpenSearch query on the path /search * It will pass the result as a OpenSearchDocument directly to the client */ @GetMapping("/search") public void search(HttpServletRequest request, - HttpServletResponse response, - @RequestParam(name = "query", required = false) String query, - @RequestParam(name = "start", required = false) Integer start, - @RequestParam(name = "rpp", required = false) Integer count, - @RequestParam(name = "format", required = false) String format, - @RequestParam(name = "sort", required = false) String sort, - @RequestParam(name = "sort_direction", required = false) String sortDirection, - @RequestParam(name = "scope", required = false) String dsoObject, - Model model) throws IOException, ServletException { + HttpServletResponse response, + @RequestParam(name = "query", required = false) String query, + @RequestParam(name = "start", required = false) Integer start, + @RequestParam(name = "rpp", required = false) Integer count, + @RequestParam(name = "format", required = false) String format, + @RequestParam(name = "sort", required = false) String sort, + @RequestParam(name = "sort_direction", required = false) String sortDirection, + @RequestParam(name = "scope", required = false) String dsoObject, + @RequestParam(name = "configuration", required = false) String configuration, + List searchFilters, + Model model) throws IOException, ServletException { context = ContextUtil.obtainContext(request); + if (start == null) { start = 0; } @@ -133,84 +145,103 @@ public class OpenSearchController { // then the rest - we are processing the query IndexableObject container = null; - // support pagination parameters - DiscoverQuery queryArgs = new DiscoverQuery(); - if (query == null) { - query = ""; - } else { - queryArgs.setQuery(query); + DiscoverQuery queryArgs; + + DiscoveryConfiguration discoveryConfiguration = null; + if (StringUtils.isNotBlank(configuration)) { + discoveryConfiguration = searchConfigurationService.getDiscoveryConfiguration(configuration); } - queryArgs.setStart(start); - queryArgs.setMaxResults(count); - queryArgs.setDSpaceObjectFilter(IndexableItem.TYPE); + if (discoveryConfiguration == null) { + discoveryConfiguration = searchConfigurationService.getDiscoveryConfiguration("default"); + } + // If we have search filters, use RestDiscoverQueryBuilder. + if (searchFilters != null && searchFilters.size() > 0) { + IndexableObject scope = scopeResolver.resolveScope(context, dsoObject); + Sort pageSort = sort == null || sortDirection == null + ? Sort.unsorted() + : Sort.by(new Sort.Order(Sort.Direction.fromString(sortDirection), sort)); + // TODO count can't be < 1 so I put an arbitrary number + Pageable page = PageRequest.of(start, count > 0 ? count : 10, pageSort); + queryArgs = restDiscoverQueryBuilder.buildQuery(context, scope, + discoveryConfiguration, query, searchFilters, IndexableItem.TYPE, page); + queryArgs.setFacetMinCount(-1); + } else { // Else, use the older behavior. + // support pagination parameters + queryArgs = new DiscoverQuery(); + if (query == null) { + query = ""; + } else { + queryArgs.setQuery(query); + } + queryArgs.setStart(start); + queryArgs.setMaxResults(count); + queryArgs.setDSpaceObjectFilter(IndexableItem.TYPE); - if (sort != null) { - DiscoveryConfiguration discoveryConfiguration = - searchConfigurationService.getDiscoveryConfiguration(""); - if (discoveryConfiguration != null) { - DiscoverySortConfiguration searchSortConfiguration = discoveryConfiguration - .getSearchSortConfiguration(); - if (searchSortConfiguration != null) { - DiscoverySortFieldConfiguration sortFieldConfiguration = searchSortConfiguration - .getSortFieldConfiguration(sort); - if (sortFieldConfiguration != null) { - String sortField = searchService - .toSortFieldIndex(sortFieldConfiguration.getMetadataField(), - sortFieldConfiguration.getType()); + if (sort != null) { + if (discoveryConfiguration != null) { + DiscoverySortConfiguration searchSortConfiguration = discoveryConfiguration + .getSearchSortConfiguration(); + if (searchSortConfiguration != null) { + DiscoverySortFieldConfiguration sortFieldConfiguration = searchSortConfiguration + .getSortFieldConfiguration(sort); + if (sortFieldConfiguration != null) { + String sortField = searchService + .toSortFieldIndex(sortFieldConfiguration.getMetadataField(), + sortFieldConfiguration.getType()); - if (sortDirection != null && sortDirection.equals("DESC")) { - queryArgs.setSortField(sortField, SORT_ORDER.desc); + if (sortDirection != null && sortDirection.equals("DESC")) { + queryArgs.setSortField(sortField, SORT_ORDER.desc); + } else { + queryArgs.setSortField(sortField, SORT_ORDER.asc); + } } else { - queryArgs.setSortField(sortField, SORT_ORDER.asc); + throw new IllegalArgumentException(sort + " is not a valid sort field"); } - } else { - throw new IllegalArgumentException(sort + " is not a valid sort field"); } } + } else { + // this is the default sort so we want to switch this to date accessioned + queryArgs.setSortField("dc.date.accessioned_dt", SORT_ORDER.desc); } - } else { - // this is the default sort so we want to switch this to date accessioned - queryArgs.setSortField("dc.date.accessioned_dt", SORT_ORDER.desc); - } - if (dsoObject != null) { - container = scopeResolver.resolveScope(context, dsoObject); - DiscoveryConfiguration discoveryConfiguration = searchConfigurationService - .getDiscoveryConfiguration(context, container); - queryArgs.setDiscoveryConfigurationName(discoveryConfiguration.getId()); - queryArgs.addFilterQueries(discoveryConfiguration.getDefaultFilterQueries() - .toArray( - new String[discoveryConfiguration.getDefaultFilterQueries() - .size()])); + if (dsoObject != null) { + container = scopeResolver.resolveScope(context, dsoObject); + discoveryConfiguration = searchConfigurationService + .getDiscoveryConfigurationByNameOrIndexableObject(context, "site", container); + queryArgs.setDiscoveryConfigurationName(discoveryConfiguration.getId()); + queryArgs.addFilterQueries(discoveryConfiguration.getDefaultFilterQueries() + .toArray( + new String[discoveryConfiguration.getDefaultFilterQueries() + .size()])); + } } // Perform the search DiscoverResult qResults = null; try { qResults = SearchUtils.getSearchService().search(context, - container, queryArgs); + container, queryArgs); } catch (SearchServiceException e) { log.error(LogHelper.getHeader(context, "opensearch", "query=" - + queryArgs.getQuery() - + ",error=" + e.getMessage()), e); + + queryArgs.getQuery() + + ",error=" + e.getMessage()), e); throw new RuntimeException(e.getMessage(), e); } // Log log.info("opensearch done, query=\"" + query + "\",results=" - + qResults.getTotalSearchResults()); + + qResults.getTotalSearchResults()); - // format and return results - Map labelMap = getLabels(request); List dsoResults = qResults.getIndexableObjects(); Document resultsDoc = openSearchService.getResultsDoc(context, format, query, - (int) qResults.getTotalSearchResults(), qResults.getStart(), - qResults.getMaxResults(), container, dsoResults, labelMap); + (int) qResults.getTotalSearchResults(), qResults.getStart(), + qResults.getMaxResults(), container, dsoResults); try { Transformer xf = TransformerFactory.newInstance().newTransformer(); response.setContentType(openSearchService.getContentType(format)); + response.addHeader(CONTENT_DISPOSITION, CONTENT_DISPOSITION_INLINE); xf.transform(new DOMSource(resultsDoc), - new StreamResult(response.getWriter())); + new StreamResult(response.getWriter())); } catch (TransformerException e) { log.error(e); throw new ServletException(e.toString()); @@ -231,7 +262,7 @@ public class OpenSearchController { */ @GetMapping("/service") public void service(HttpServletRequest request, - HttpServletResponse response) throws IOException { + HttpServletResponse response) throws IOException { log.debug("Show OpenSearch Service document"); if (openSearchService == null) { openSearchService = UtilServiceFactory.getInstance().getOpenSearchService(); @@ -240,7 +271,7 @@ public class OpenSearchController { String svcDescrip = openSearchService.getDescription(null); log.debug("opensearchdescription is " + svcDescrip); response.setContentType(openSearchService - .getContentType("opensearchdescription")); + .getContentType("opensearchdescription")); response.setContentLength(svcDescrip.length()); response.getWriter().write(svcDescrip); } else { @@ -274,20 +305,4 @@ public class OpenSearchController { public void setOpenSearchService(OpenSearchService oSS) { openSearchService = oSS; } - - - /** - * Internal method to get labels for the returned document - */ - private Map getLabels(HttpServletRequest request) { - // TODO: get strings from translation file or configuration - Map labelMap = new HashMap(); - labelMap.put(SyndicationFeed.MSG_UNTITLED, "notitle"); - labelMap.put(SyndicationFeed.MSG_LOGO_TITLE, "logo.title"); - labelMap.put(SyndicationFeed.MSG_FEED_DESCRIPTION, "general-feed.description"); - for (String selector : SyndicationFeed.getDescriptionSelectors()) { - labelMap.put("metadata." + selector, selector); - } - return labelMap; - } } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/RootRestResourceController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/RootRestResourceController.java index d6ed84c3d6..51ef5ea583 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/RootRestResourceController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/RootRestResourceController.java @@ -43,6 +43,6 @@ public class RootRestResourceController { @RequestMapping(method = RequestMethod.GET) public RootResource listDefinedEndpoint(HttpServletRequest request) { - return converter.toResource(rootRestRepository.getRoot()); + return converter.toResource(rootRestRepository.getRoot(request)); } } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/UUIDLookupRestController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/UUIDLookupRestController.java index 8a35794aa1..3da84cc2d3 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/UUIDLookupRestController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/UUIDLookupRestController.java @@ -24,9 +24,11 @@ import org.dspace.app.rest.model.DSpaceObjectRest; import org.dspace.app.rest.utils.ContextUtil; import org.dspace.app.rest.utils.DSpaceObjectUtils; import org.dspace.app.rest.utils.Utils; +import org.dspace.authorize.AuthorizeException; +import org.dspace.authorize.service.AuthorizeService; import org.dspace.content.DSpaceObject; +import org.dspace.core.Constants; import org.dspace.core.Context; -import org.dspace.discovery.SearchServiceException; import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.hateoas.Link; @@ -65,6 +67,9 @@ public class UUIDLookupRestController implements InitializingBean { @Autowired private DiscoverableEndpointsService discoverableEndpointsService; + @Autowired + private AuthorizeService authorizeService; + @Autowired private ConverterService converter; @@ -85,13 +90,14 @@ public class UUIDLookupRestController implements InitializingBean { public void getDSObyIdentifier(HttpServletRequest request, HttpServletResponse response, @RequestParam(PARAM) UUID uuid) - throws IOException, SQLException, SearchServiceException { + throws IOException, SQLException, AuthorizeException { Context context = null; try { context = ContextUtil.obtainContext(request); DSpaceObject dso = dspaceObjectUtil.findDSpaceObject(context, uuid); if (dso != null) { + authorizeService.authorizeAction(context, dso, Constants.READ); DSpaceObjectRest dsor = converter.toRest(dso, utils.obtainProjection()); URI link = linkTo(dsor.getController(), dsor.getCategory(), dsor.getTypePlural()).slash(dsor.getId()) .toUri(); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/DiscoverFacetConfigurationConverter.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/DiscoverFacetConfigurationConverter.java index 32768d4196..c778b04856 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/DiscoverFacetConfigurationConverter.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/DiscoverFacetConfigurationConverter.java @@ -42,6 +42,7 @@ public class DiscoverFacetConfigurationConverter { SearchFacetEntryRest facetEntry = new SearchFacetEntryRest(discoverySearchFilterFacet.getIndexFieldName()); facetEntry.setFacetType(discoverySearchFilterFacet.getType()); facetEntry.setFacetLimit(discoverySearchFilterFacet.getFacetLimit()); + facetEntry.setOpenByDefault(discoverySearchFilterFacet.isOpenByDefault()); facetConfigurationRest.addSidebarFacet(facetEntry); } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/DiscoverFacetResultsConverter.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/DiscoverFacetResultsConverter.java index 8d4610b6ba..e0af907730 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/DiscoverFacetResultsConverter.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/DiscoverFacetResultsConverter.java @@ -109,6 +109,7 @@ public class DiscoverFacetResultsConverter { } facetEntryRest.setFacetLimit(field.getFacetLimit()); + facetEntryRest.setOpenByDefault(field.isOpenByDefault()); //We requested one extra facet value. Check if that value is present to indicate that there are more results facetEntryRest.setHasMore(facetResults.size() > page.getPageSize()); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/DiscoverFacetsConverter.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/DiscoverFacetsConverter.java index cbbdc3e9cb..d099c60166 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/DiscoverFacetsConverter.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/DiscoverFacetsConverter.java @@ -83,6 +83,7 @@ public class DiscoverFacetsConverter { if (field.exposeMinAndMaxValue()) { handleExposeMinMaxValues(context, field, facetEntry); } + facetEntry.setOpenByDefault(field.isOpenByDefault()); facetEntry.setExposeMinMax(field.exposeMinAndMaxValue()); facetEntry.setFacetType(field.getType()); for (DiscoverResult.FacetResult value : CollectionUtils.emptyIfNull(facetValues)) { diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/RootConverter.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/RootConverter.java index 61f18a5b3c..bfc125fd86 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/RootConverter.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/RootConverter.java @@ -9,6 +9,7 @@ package org.dspace.app.rest.converter; import static org.dspace.app.util.Util.getSourceVersion; +import jakarta.servlet.http.HttpServletRequest; import org.dspace.app.rest.model.RootRest; import org.dspace.services.ConfigurationService; import org.springframework.beans.factory.annotation.Autowired; @@ -23,12 +24,21 @@ public class RootConverter { @Autowired private ConfigurationService configurationService; - public RootRest convert() { + public RootRest convert(HttpServletRequest request) { RootRest rootRest = new RootRest(); rootRest.setDspaceName(configurationService.getProperty("dspace.name")); rootRest.setDspaceUI(configurationService.getProperty("dspace.ui.url")); - rootRest.setDspaceServer(configurationService.getProperty("dspace.server.url")); + String requestUrl = request.getRequestURL().toString(); + String dspaceUrl = configurationService.getProperty("dspace.server.url"); + String dspaceSSRUrl = configurationService.getProperty("dspace.server.ssr.url", dspaceUrl); + if (!dspaceUrl.equals(dspaceSSRUrl) && requestUrl.startsWith(dspaceSSRUrl)) { + rootRest.setDspaceServer(dspaceSSRUrl); + } else { + rootRest.setDspaceServer(dspaceUrl); + } rootRest.setDspaceVersion("DSpace " + getSourceVersion()); return rootRest; } + + } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/SearchEventConverter.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/SearchEventConverter.java index e9786962e0..dcf42f0998 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/SearchEventConverter.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/SearchEventConverter.java @@ -13,7 +13,8 @@ import java.util.LinkedList; import java.util.List; import jakarta.servlet.http.HttpServletRequest; -import org.apache.log4j.Logger; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.dspace.app.rest.model.PageRest; import org.dspace.app.rest.model.SearchEventRest; import org.dspace.app.rest.model.SearchResultsRest; @@ -31,7 +32,7 @@ import org.springframework.stereotype.Component; @Component public class SearchEventConverter { /* Log4j logger */ - private static final Logger log = Logger.getLogger(SearchEventConverter.class); + private static final Logger log = LogManager.getLogger(SearchEventConverter.class); @Autowired private ScopeResolver scopeResolver; @@ -66,8 +67,8 @@ public class SearchEventConverter { if (searchEventRest.getScope() != null) { IndexableObject scopeObject = scopeResolver.resolveScope(context, String.valueOf(searchEventRest.getScope())); - if (scopeObject instanceof DSpaceObject) { - usageSearchEvent.setScope((DSpaceObject) scopeObject); + if (scopeObject != null && scopeObject.getIndexedObject() instanceof DSpaceObject) { + usageSearchEvent.setScope((DSpaceObject) scopeObject.getIndexedObject()); } } usageSearchEvent.setConfiguration(searchEventRest.getConfiguration()); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/SubmissionSectionConverter.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/SubmissionSectionConverter.java index 0391cbce7a..3cd263493b 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/SubmissionSectionConverter.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/SubmissionSectionConverter.java @@ -10,6 +10,7 @@ package org.dspace.app.rest.converter; import java.sql.SQLException; import org.apache.logging.log4j.Logger; +import org.dspace.app.rest.model.ScopeEnum; import org.dspace.app.rest.model.SubmissionSectionRest; import org.dspace.app.rest.model.SubmissionVisibilityRest; import org.dspace.app.rest.model.VisibilityEnum; @@ -41,6 +42,7 @@ public class SubmissionSectionConverter implements DSpaceConverter convert(List searchFilters) { List transformedSearchFilters = new LinkedList<>(); for (SearchFilter searchFilter : CollectionUtils.emptyIfNull(searchFilters)) { - if (StringUtils.equals(searchFilter.getOperator(), "query")) { + if (StringUtils.equals(searchFilter.getOperator(), OPERATOR_QUERY)) { SearchFilter transformedSearchFilter = convertQuerySearchFilterIntoStandardSearchFilter(searchFilter); transformedSearchFilters.add(transformedSearchFilter); } else { @@ -46,10 +48,10 @@ public class SearchQueryConverter { /** * This method takes care of the converter of a specific SearchFilter given to it * - * @param searchFilter The SearchFilter to be transformed - * @return The transformed SearchFilter + * @param searchFilter searchFilter to be transformed + * @return transformed SearchFilter */ - public SearchFilter convertQuerySearchFilterIntoStandardSearchFilter(SearchFilter searchFilter) { + private SearchFilter convertQuerySearchFilterIntoStandardSearchFilter(SearchFilter searchFilter) { RestSearchOperator restSearchOperator = RestSearchOperator.forQuery(searchFilter.getValue()); SearchFilter transformedSearchFilter = new SearchFilter(searchFilter.getName(), restSearchOperator.getDspaceOperator(), restSearchOperator.extractValue(searchFilter.getValue())); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/AuthorizationRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/AuthorizationRest.java index fa463a7c39..cd3e33b9e2 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/AuthorizationRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/AuthorizationRest.java @@ -18,9 +18,9 @@ import org.dspace.app.rest.RestResourceController; * @author Andrea Bollini (andrea.bollini at 4science.it) */ @LinksRest(links = { - @LinkRest(method = "getEperson", name = AuthorizationRest.EPERSON), - @LinkRest(method = "getFeature", name = AuthorizationRest.FEATURE), - @LinkRest(method = "getObject", name = AuthorizationRest.OBJECT) + @LinkRest(method = "getEperson", name = AuthorizationRest.EPERSON), + @LinkRest(method = "getFeature", name = AuthorizationRest.FEATURE), + @LinkRest(method = "getObject", name = AuthorizationRest.OBJECT) }) public class AuthorizationRest extends BaseObjectRest { public static final String NAME = "authorization"; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/BitstreamRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/BitstreamRest.java index d2c2268b3f..d456f72223 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/BitstreamRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/BitstreamRest.java @@ -16,18 +16,9 @@ import com.fasterxml.jackson.annotation.JsonProperty.Access; * @author Andrea Bollini (andrea.bollini at 4science.it) */ @LinksRest(links = { - @LinkRest( - name = BitstreamRest.BUNDLE, - method = "getBundle" - ), - @LinkRest( - name = BitstreamRest.FORMAT, - method = "getFormat" - ), - @LinkRest( - name = BitstreamRest.THUMBNAIL, - method = "getThumbnail" - ) + @LinkRest(name = BitstreamRest.BUNDLE, method = "getBundle"), + @LinkRest(name = BitstreamRest.FORMAT, method = "getFormat"), + @LinkRest(name = BitstreamRest.THUMBNAIL, method = "getThumbnail") }) public class BitstreamRest extends DSpaceObjectRest { public static final String PLURAL_NAME = "bitstreams"; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/BrowseIndexRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/BrowseIndexRest.java index a3c0b37ba5..e5b0894799 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/BrowseIndexRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/BrowseIndexRest.java @@ -20,14 +20,8 @@ import org.dspace.app.rest.RestResourceController; * @author Andrea Bollini (andrea.bollini at 4science.it) */ @LinksRest(links = { - @LinkRest( - name = BrowseIndexRest.LINK_ITEMS, - method = "listBrowseItems" - ), - @LinkRest( - name = BrowseIndexRest.LINK_ENTRIES, - method = "listBrowseEntries" - ) + @LinkRest(name = BrowseIndexRest.LINK_ITEMS, method = "listBrowseItems"), + @LinkRest(name = BrowseIndexRest.LINK_ENTRIES, method = "listBrowseEntries") }) public class BrowseIndexRest extends BaseObjectRest { private static final long serialVersionUID = -4870333170249999559L; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/BundleRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/BundleRest.java index 1ec9f448dd..4a417e6c54 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/BundleRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/BundleRest.java @@ -16,18 +16,9 @@ import com.fasterxml.jackson.annotation.JsonProperty; * @author Jelle Pelgrims (jelle.pelgrims at atmire.com) */ @LinksRest(links = { - @LinkRest( - name = BundleRest.ITEM, - method = "getItem" - ), - @LinkRest( - name = BundleRest.BITSTREAMS, - method = "getBitstreams" - ), - @LinkRest( - name = BundleRest.PRIMARY_BITSTREAM, - method = "getPrimaryBitstream" - ) + @LinkRest(name = BundleRest.ITEM, method = "getItem"), + @LinkRest(name = BundleRest.BITSTREAMS, method = "getBitstreams"), + @LinkRest(name = BundleRest.PRIMARY_BITSTREAM, method = "getPrimaryBitstream") }) public class BundleRest extends DSpaceObjectRest { public static final String NAME = "bundle"; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/ClaimedTaskRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/ClaimedTaskRest.java index 0973fac987..d29bf7a7ce 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/ClaimedTaskRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/ClaimedTaskRest.java @@ -16,10 +16,7 @@ import org.dspace.app.rest.RestResourceController; * @author Andrea Bollini (andrea.bollini at 4science.it) */ @LinksRest(links = { - @LinkRest( - name = ClaimedTaskRest.STEP, - method = "getStep" - ) + @LinkRest(name = ClaimedTaskRest.STEP, method = "getStep") }) public class ClaimedTaskRest extends BaseObjectRest { public static final String NAME = "claimedtask"; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/CollectionRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/CollectionRest.java index f00bb88395..34faba4cb4 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/CollectionRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/CollectionRest.java @@ -15,38 +15,14 @@ import com.fasterxml.jackson.annotation.JsonProperty; * @author Andrea Bollini (andrea.bollini at 4science.it) */ @LinksRest(links = { - @LinkRest( - name = CollectionRest.LICENSE, - method = "getLicense" - ), - @LinkRest( - name = CollectionRest.LOGO, - method = "getLogo" - ), - @LinkRest( - name = CollectionRest.MAPPED_ITEMS, - method = "getMappedItems" - ), - @LinkRest( - name = CollectionRest.PARENT_COMMUNITY, - method = "getParentCommunity" - ), - @LinkRest( - name = CollectionRest.ADMIN_GROUP, - method = "getAdminGroup" - ), - @LinkRest( - name = CollectionRest.SUBMITTERS_GROUP, - method = "getSubmittersGroup" - ), - @LinkRest( - name = CollectionRest.ITEM_READ_GROUP, - method = "getItemReadGroup" - ), - @LinkRest( - name = CollectionRest.BITSTREAM_READ_GROUP, - method = "getBitstreamReadGroup" - ), + @LinkRest(name = CollectionRest.LICENSE, method = "getLicense"), + @LinkRest(name = CollectionRest.LOGO, method = "getLogo"), + @LinkRest(name = CollectionRest.MAPPED_ITEMS, method = "getMappedItems"), + @LinkRest(name = CollectionRest.PARENT_COMMUNITY, method = "getParentCommunity"), + @LinkRest(name = CollectionRest.ADMIN_GROUP, method = "getAdminGroup"), + @LinkRest(name = CollectionRest.SUBMITTERS_GROUP, method = "getSubmittersGroup"), + @LinkRest(name = CollectionRest.ITEM_READ_GROUP, method = "getItemReadGroup"), + @LinkRest(name = CollectionRest.BITSTREAM_READ_GROUP, method = "getBitstreamReadGroup"), }) public class CollectionRest extends DSpaceObjectRest { public static final String NAME = "collection"; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/CommunityRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/CommunityRest.java index 0004e0b91c..e70b30803d 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/CommunityRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/CommunityRest.java @@ -15,26 +15,11 @@ import com.fasterxml.jackson.annotation.JsonProperty; * @author Andrea Bollini (andrea.bollini at 4science.it) */ @LinksRest(links = { - @LinkRest( - name = CommunityRest.COLLECTIONS, - method = "getCollections" - ), - @LinkRest( - name = CommunityRest.LOGO, - method = "getLogo" - ), - @LinkRest( - name = CommunityRest.SUBCOMMUNITIES, - method = "getSubcommunities" - ), - @LinkRest( - name = CommunityRest.PARENT_COMMUNITY, - method = "getParentCommunity" - ), - @LinkRest( - name = CommunityRest.ADMIN_GROUP, - method = "getAdminGroup" - ) + @LinkRest(name = CommunityRest.COLLECTIONS, method = "getCollections"), + @LinkRest(name = CommunityRest.LOGO, method = "getLogo"), + @LinkRest(name = CommunityRest.SUBCOMMUNITIES, method = "getSubcommunities"), + @LinkRest(name = CommunityRest.PARENT_COMMUNITY, method = "getParentCommunity"), + @LinkRest(name = CommunityRest.ADMIN_GROUP, method = "getAdminGroup") }) public class CommunityRest extends DSpaceObjectRest { public static final String NAME = "community"; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/EPersonRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/EPersonRest.java index c06ed0e3fe..db24340025 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/EPersonRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/EPersonRest.java @@ -20,10 +20,7 @@ import org.dspace.app.rest.RestResourceController; * @author Andrea Bollini (andrea.bollini at 4science.it) */ @LinksRest(links = { - @LinkRest( - name = EPersonRest.GROUPS, - method = "getGroups" - ) + @LinkRest(name = EPersonRest.GROUPS, method = "getGroups") }) public class EPersonRest extends DSpaceObjectRest { public static final String NAME = "eperson"; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/EntityTypeRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/EntityTypeRest.java index 9d4a729ded..e73aa70918 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/EntityTypeRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/EntityTypeRest.java @@ -15,10 +15,7 @@ import org.dspace.app.rest.RestResourceController; * Refer to {@link org.dspace.content.EntityType} for explanation of the properties */ @LinksRest(links = { - @LinkRest( - name = EntityTypeRest.RELATION_SHIP_TYPES, - method = "getEntityTypeRelationship" - ) + @LinkRest(name = EntityTypeRest.RELATION_SHIP_TYPES, method = "getEntityTypeRelationship") }) public class EntityTypeRest extends BaseObjectRest { diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/ExternalSourceRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/ExternalSourceRest.java index 58402954e8..21f41241b2 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/ExternalSourceRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/ExternalSourceRest.java @@ -13,10 +13,7 @@ import org.dspace.app.rest.RestResourceController; * This class serves as a REST representation for an External Source */ @LinksRest(links = { - @LinkRest( - name = ExternalSourceRest.ENTITY_TYPES, - method = "getSupportedEntityTypes" - ) + @LinkRest(name = ExternalSourceRest.ENTITY_TYPES, method = "getSupportedEntityTypes") }) public class ExternalSourceRest extends BaseObjectRest { diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/GroupRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/GroupRest.java index 7d56af2e72..0a4963b66f 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/GroupRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/GroupRest.java @@ -18,18 +18,9 @@ import org.dspace.app.rest.RestResourceController; */ @JsonIgnoreProperties(ignoreUnknown = true) @LinksRest(links = { - @LinkRest( - name = GroupRest.SUBGROUPS, - method = "getGroups" - ), - @LinkRest( - name = GroupRest.EPERSONS, - method = "getMembers" - ), - @LinkRest( - name = GroupRest.OBJECT, - method = "getParentObject" - ) + @LinkRest(name = GroupRest.SUBGROUPS, method = "getGroups"), + @LinkRest(name = GroupRest.EPERSONS, method = "getMembers"), + @LinkRest(name = GroupRest.OBJECT, method = "getParentObject") }) public class GroupRest extends DSpaceObjectRest { public static final String NAME = "group"; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/ItemRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/ItemRest.java index b3ae373cee..a47667441c 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/ItemRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/ItemRest.java @@ -17,46 +17,16 @@ import com.fasterxml.jackson.annotation.JsonProperty; * @author Andrea Bollini (andrea.bollini at 4science.it) */ @LinksRest(links = { - @LinkRest( - name = ItemRest.ACCESS_STATUS, - method = "getAccessStatus" - ), - @LinkRest( - name = ItemRest.BUNDLES, - method = "getBundles" - ), - @LinkRest( - name = ItemRest.IDENTIFIERS, - method = "getIdentifiers" - ), - @LinkRest( - name = ItemRest.MAPPED_COLLECTIONS, - method = "getMappedCollections" - ), - @LinkRest( - name = ItemRest.OWNING_COLLECTION, - method = "getOwningCollection" - ), - @LinkRest( - name = ItemRest.RELATIONSHIPS, - method = "getRelationships" - ), - @LinkRest( - name = ItemRest.VERSION, - method = "getItemVersion" - ), - @LinkRest( - name = ItemRest.TEMPLATE_ITEM_OF, - method = "getTemplateItemOf" - ), - @LinkRest( - name = ItemRest.THUMBNAIL, - method = "getThumbnail" - ), - @LinkRest( - name = ItemRest.SUBMITTER, - method = "getItemSubmitter" - ) + @LinkRest(name = ItemRest.ACCESS_STATUS, method = "getAccessStatus"), + @LinkRest(name = ItemRest.BUNDLES, method = "getBundles"), + @LinkRest(name = ItemRest.IDENTIFIERS, method = "getIdentifiers"), + @LinkRest(name = ItemRest.MAPPED_COLLECTIONS, method = "getMappedCollections"), + @LinkRest(name = ItemRest.OWNING_COLLECTION, method = "getOwningCollection"), + @LinkRest(name = ItemRest.RELATIONSHIPS, method = "getRelationships"), + @LinkRest(name = ItemRest.VERSION, method = "getItemVersion"), + @LinkRest(name = ItemRest.TEMPLATE_ITEM_OF, method = "getTemplateItemOf"), + @LinkRest(name = ItemRest.THUMBNAIL, method = "getThumbnail"), + @LinkRest(name = ItemRest.SUBMITTER, method = "getItemSubmitter") }) public class ItemRest extends DSpaceObjectRest { public static final String NAME = "item"; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/OrcidHistoryRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/OrcidHistoryRest.java index 2c4c7cbe60..433d5626ca 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/OrcidHistoryRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/OrcidHistoryRest.java @@ -39,7 +39,7 @@ public class OrcidHistoryRest extends BaseObjectRest { private String responseMessage; - public OrcidHistoryRest(){} + public OrcidHistoryRest() {} @Override @JsonProperty(access = JsonProperty.Access.READ_ONLY) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/PoolTaskRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/PoolTaskRest.java index 0b66f0604b..94c7003733 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/PoolTaskRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/PoolTaskRest.java @@ -17,10 +17,7 @@ import org.dspace.xmlworkflow.storedcomponents.PoolTask; * @author Andrea Bollini (andrea.bollini at 4science.it) */ @LinksRest(links = { - @LinkRest( - name = PoolTaskRest.STEP, - method = "getStep" - ) + @LinkRest(name = PoolTaskRest.STEP, method = "getStep") }) public class PoolTaskRest extends BaseObjectRest { public static final String NAME = "pooltask"; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/ProcessRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/ProcessRest.java index d3d88c2776..fee104b4e3 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/ProcessRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/ProcessRest.java @@ -21,18 +21,9 @@ import org.dspace.scripts.Process; * This class serves as a REST representation for the {@link Process} class */ @LinksRest(links = { - @LinkRest( - name = ProcessRest.FILES, - method = "getFilesFromProcess" - ), - @LinkRest( - name = ProcessRest.FILE_TYPES, - method = "getFileTypesFromProcess" - ), - @LinkRest( - name = ProcessRest.OUTPUT, - method = "getOutputFromProcess" - ) + @LinkRest(name = ProcessRest.FILES, method = "getFilesFromProcess"), + @LinkRest(name = ProcessRest.FILE_TYPES, method = "getFileTypesFromProcess"), + @LinkRest(name = ProcessRest.OUTPUT, method = "getOutputFromProcess") }) public class ProcessRest extends BaseObjectRest { public static final String NAME = "process"; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/RelationshipRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/RelationshipRest.java index 76a7a43486..723f7e148b 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/RelationshipRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/RelationshipRest.java @@ -19,10 +19,7 @@ import org.dspace.app.rest.RestResourceController; * Refer to {@link org.dspace.content.Relationship} for explanation about the properties */ @LinksRest(links = { - @LinkRest( - name = RelationshipRest.RELATIONSHIP_TYPE, - method = "getRelationshipType" - ) + @LinkRest(name = RelationshipRest.RELATIONSHIP_TYPE, method = "getRelationshipType") }) public class RelationshipRest extends BaseObjectRest { public static final String NAME = "relationship"; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/ResearcherProfileRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/ResearcherProfileRest.java index 13faa2e2bb..629dbdf858 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/ResearcherProfileRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/ResearcherProfileRest.java @@ -20,8 +20,8 @@ import org.dspace.app.rest.RestResourceController; * */ @LinksRest(links = { - @LinkRest(name = ResearcherProfileRest.ITEM, method = "getItem"), - @LinkRest(name = ResearcherProfileRest.EPERSON, method = "getEPerson") + @LinkRest(name = ResearcherProfileRest.ITEM, method = "getItem"), + @LinkRest(name = ResearcherProfileRest.EPERSON, method = "getEPerson") }) public class ResearcherProfileRest extends BaseObjectRest { diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/SearchFacetEntryRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/SearchFacetEntryRest.java index aafa3a1108..7d9eadb286 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/SearchFacetEntryRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/SearchFacetEntryRest.java @@ -13,6 +13,7 @@ import java.util.List; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import org.dspace.app.rest.DiscoveryRestController; +import org.dspace.discovery.configuration.DiscoverySearchFilter; import org.dspace.discovery.configuration.DiscoverySearchFilterFacet; /** @@ -31,6 +32,9 @@ public class SearchFacetEntryRest extends RestAddressableModel { private Boolean hasMore = null; private int facetLimit; + @JsonIgnore + private Boolean isOpenByDefault; + @JsonIgnore private boolean exposeMinMax = false; @@ -107,6 +111,16 @@ public class SearchFacetEntryRest extends RestAddressableModel { this.facetLimit = facetLimit; } + public void setOpenByDefault(boolean isOpenByDefault) { + this.isOpenByDefault = Boolean.valueOf(isOpenByDefault); + } + /** + * See documentation at {@link DiscoverySearchFilter#isOpenByDefault()} + */ + public Boolean isOpenByDefault() { + return this.isOpenByDefault; + } + /** * See documentation at {@link DiscoverySearchFilterFacet#exposeMinAndMaxValue()} */ diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/SuggestionRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/SuggestionRest.java index c7210e8925..7b1a05127f 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/SuggestionRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/SuggestionRest.java @@ -21,7 +21,9 @@ import org.dspace.app.rest.RestResourceController; * * @author Andrea Bollini (andrea.bollini at 4science.it) */ -@LinksRest(links = { @LinkRest(name = SuggestionRest.TARGET, method = "getTarget") }) +@LinksRest(links = { + @LinkRest(name = SuggestionRest.TARGET, method = "getTarget") +}) public class SuggestionRest extends BaseObjectRest { private static final long serialVersionUID = 1L; public static final String NAME = "suggestion"; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/SuggestionTargetRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/SuggestionTargetRest.java index 65764507e2..b6518eff74 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/SuggestionTargetRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/SuggestionTargetRest.java @@ -19,7 +19,7 @@ import org.dspace.app.rest.RestResourceController; * @author Andrea Bollini (andrea.bollini at 4science.it) */ @LinksRest(links = { - @LinkRest(name = SuggestionTargetRest.TARGET, method = "getTarget") + @LinkRest(name = SuggestionTargetRest.TARGET, method = "getTarget") }) public class SuggestionTargetRest extends BaseObjectRest { private static final long serialVersionUID = 1L; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/VersionHistoryRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/VersionHistoryRest.java index 5aab7028a8..80f704c779 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/VersionHistoryRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/VersionHistoryRest.java @@ -13,14 +13,8 @@ import org.dspace.app.rest.RestResourceController; * The REST object for the {@link org.dspace.versioning.VersionHistory} object */ @LinksRest(links = { - @LinkRest( - name = VersionHistoryRest.VERSIONS, - method = "getVersions" - ), - @LinkRest( - name = VersionHistoryRest.DRAFT_VERSION, - method = "getDraftVersion" - ) + @LinkRest(name = VersionHistoryRest.VERSIONS, method = "getVersions"), + @LinkRest(name = VersionHistoryRest.DRAFT_VERSION, method = "getDraftVersion") }) public class VersionHistoryRest extends BaseObjectRest { diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/VersionRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/VersionRest.java index 21bf82804d..d9ebdd67e4 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/VersionRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/VersionRest.java @@ -16,14 +16,8 @@ import org.dspace.app.rest.RestResourceController; * The REST object for the {@link org.dspace.versioning.Version} objects */ @LinksRest(links = { - @LinkRest( - name = VersionRest.VERSION_HISTORY, - method = "getVersionHistory" - ), - @LinkRest( - name = VersionRest.ITEM, - method = "getVersionItem" - ) + @LinkRest(name = VersionRest.VERSION_HISTORY, method = "getVersionHistory"), + @LinkRest(name = VersionRest.ITEM, method = "getVersionItem") }) public class VersionRest extends BaseObjectRest { diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/VocabularyEntryDetailsRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/VocabularyEntryDetailsRest.java index e5869a8525..884e14642c 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/VocabularyEntryDetailsRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/VocabularyEntryDetailsRest.java @@ -18,9 +18,9 @@ import org.dspace.app.rest.RestResourceController; * @author Andrea Bollini (andrea.bollini at 4science.it) */ @LinksRest(links = { - @LinkRest(name = VocabularyEntryDetailsRest.PARENT, method = "getParent"), - @LinkRest(name = VocabularyEntryDetailsRest.CHILDREN, method = "getChildren") - }) + @LinkRest(name = VocabularyEntryDetailsRest.PARENT, method = "getParent"), + @LinkRest(name = VocabularyEntryDetailsRest.CHILDREN, method = "getChildren") +}) public class VocabularyEntryDetailsRest extends BaseObjectRest { public static final String PLURAL_NAME = "vocabularyEntryDetails"; public static final String NAME = "vocabularyEntryDetail"; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/VocabularyRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/VocabularyRest.java index f119059c2b..a54d93c643 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/VocabularyRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/VocabularyRest.java @@ -15,9 +15,7 @@ import org.dspace.app.rest.RestResourceController; * @author Andrea Bollini (andrea.bollini at 4science.it) */ @LinksRest(links = { - @LinkRest(name = VocabularyRest.ENTRIES, - method = "filter" - ), + @LinkRest(name = VocabularyRest.ENTRIES, method = "filter"), }) public class VocabularyRest extends BaseObjectRest { diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/WorkflowDefinitionRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/WorkflowDefinitionRest.java index 0ec967d098..9cef79aaf3 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/WorkflowDefinitionRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/WorkflowDefinitionRest.java @@ -18,14 +18,8 @@ import org.dspace.app.rest.RestResourceController; * @author Maria Verdonck (Atmire) on 11/12/2019 */ @LinksRest(links = { - @LinkRest( - name = WorkflowDefinitionRest.COLLECTIONS_MAPPED_TO, - method = "getCollections" - ), - @LinkRest( - name = WorkflowDefinitionRest.STEPS, - method = "getSteps" - ) + @LinkRest(name = WorkflowDefinitionRest.COLLECTIONS_MAPPED_TO, method = "getCollections"), + @LinkRest(name = WorkflowDefinitionRest.STEPS, method = "getSteps") }) public class WorkflowDefinitionRest extends BaseObjectRest { diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/WorkflowItemRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/WorkflowItemRest.java index 65fa531c5e..d08abb3546 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/WorkflowItemRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/WorkflowItemRest.java @@ -15,22 +15,10 @@ import org.dspace.app.rest.RestResourceController; * @author Andrea Bollini (andrea.bollini at 4science.it) */ @LinksRest(links = { - @LinkRest( - name = WorkflowItemRest.STEP, - method = "getStep" - ), - @LinkRest( - name = WorkflowItemRest.SUBMITTER, - method = "getWorkflowItemSubmitter" - ), - @LinkRest( - name = WorkflowItemRest.ITEM, - method = "getWorkflowItemItem" - ), - @LinkRest( - name = WorkflowItemRest.COLLECTION, - method = "getWorkflowItemCollection" - ) + @LinkRest(name = WorkflowItemRest.STEP, method = "getStep"), + @LinkRest(name = WorkflowItemRest.SUBMITTER, method = "getWorkflowItemSubmitter"), + @LinkRest(name = WorkflowItemRest.ITEM, method = "getWorkflowItemItem"), + @LinkRest(name = WorkflowItemRest.COLLECTION, method = "getWorkflowItemCollection") }) public class WorkflowItemRest extends AInprogressSubmissionRest { public static final String NAME = "workflowitem"; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/WorkflowStepRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/WorkflowStepRest.java index b3397721c1..53ddf38709 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/WorkflowStepRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/WorkflowStepRest.java @@ -18,10 +18,7 @@ import org.dspace.app.rest.RestResourceController; * @author Maria Verdonck (Atmire) on 10/01/2020 */ @LinksRest(links = { - @LinkRest( - name = WorkflowStepRest.ACTIONS, - method = "getActions" - ), + @LinkRest(name = WorkflowStepRest.ACTIONS, method = "getActions"), }) public class WorkflowStepRest extends BaseObjectRest { diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/WorkspaceItemRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/WorkspaceItemRest.java index e311cd2592..8e0d52123f 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/WorkspaceItemRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/WorkspaceItemRest.java @@ -15,22 +15,10 @@ import org.dspace.app.rest.RestResourceController; * @author Andrea Bollini (andrea.bollini at 4science.it) */ @LinksRest(links = { - @LinkRest( - name = WorkspaceItemRest.SUPERVISION_ORDERS, - method = "getSupervisionOrders" - ), - @LinkRest( - name = WorkspaceItemRest.SUBMITTER, - method = "getWorkspaceItemSubmitter" - ), - @LinkRest( - name = WorkspaceItemRest.ITEM, - method = "getWorkspaceItemItem" - ), - @LinkRest( - name = WorkspaceItemRest.COLLECTION, - method = "getWorkspaceItemCollection" - ) + @LinkRest(name = WorkspaceItemRest.SUPERVISION_ORDERS, method = "getSupervisionOrders"), + @LinkRest(name = WorkspaceItemRest.SUBMITTER, method = "getWorkspaceItemSubmitter"), + @LinkRest(name = WorkspaceItemRest.ITEM, method = "getWorkspaceItemItem"), + @LinkRest(name = WorkspaceItemRest.COLLECTION, method = "getWorkspaceItemCollection") }) public class WorkspaceItemRest extends AInprogressSubmissionRest { public static final String NAME = "workspaceitem"; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/DSpaceRestRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/DSpaceRestRepository.java index 296c4322a3..cf7c556e3a 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/DSpaceRestRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/DSpaceRestRepository.java @@ -17,7 +17,6 @@ import java.util.UUID; import com.fasterxml.jackson.databind.JsonNode; import jakarta.servlet.http.HttpServletRequest; -import org.apache.logging.log4j.Logger; import org.dspace.app.rest.exception.DSpaceBadRequestException; import org.dspace.app.rest.exception.RESTAuthorizationException; import org.dspace.app.rest.exception.RepositoryMethodNotImplementedException; @@ -26,7 +25,6 @@ import org.dspace.app.rest.model.ItemRest; import org.dspace.app.rest.model.RestAddressableModel; import org.dspace.app.rest.model.patch.Patch; import org.dspace.authorize.AuthorizeException; -import org.dspace.content.service.MetadataFieldService; import org.dspace.core.Context; import org.springframework.beans.factory.BeanNameAware; import org.springframework.beans.factory.annotation.Autowired; @@ -50,17 +48,12 @@ public abstract class DSpaceRestRepository, PagingAndSortingRepository, BeanNameAware { - private static final Logger log = org.apache.logging.log4j.LogManager.getLogger(DSpaceRestRepository.class); - private String thisRepositoryBeanName; private DSpaceRestRepository thisRepository; @Autowired private ApplicationContext applicationContext; - @Autowired - private MetadataFieldService metadataFieldService; - /** * From BeanNameAware. Allows us to capture the name of the bean, so we can load it into thisRepository. * See getThisRepository() method. diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ProcessRestRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ProcessRestRepository.java index 24152ed569..6ec7c62aa6 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ProcessRestRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ProcessRestRepository.java @@ -14,6 +14,7 @@ import java.util.List; import java.util.UUID; import java.util.stream.Collectors; +import jakarta.annotation.PostConstruct; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -65,6 +66,12 @@ public class ProcessRestRepository extends DSpaceRestRepository { public static final String OPERATION_PATH_SECTIONS = "sections"; + public static final String REQUESTPARAMETER_EXPUNGE = "expunge"; private static final Logger log = LogManager.getLogger(); @@ -239,13 +240,25 @@ public class WorkflowItemRestRepository extends DSpaceRestRepository extends PatchOperation { return object; } } catch (AuthorizeException e) { - throw new RESTAuthorizationException("Unauthorized user for item withdrawal/reinstation"); + throw new RESTAuthorizationException("Unauthorized user for item withdraw / reinstate operation"); } catch (SQLException e) { - throw new DSpaceBadRequestException("SQL exception during item withdrawal/reinstation"); + throw new DSpaceBadRequestException("SQL exception during item withdraw / reinstate operation"); } } else { throw new DSpaceBadRequestException("ItemWithdrawReplaceOperation does not support this operation"); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/scripts/handler/impl/RestDSpaceRunnableHandler.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/scripts/handler/impl/RestDSpaceRunnableHandler.java index 596ab44290..ee67baa8ab 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/scripts/handler/impl/RestDSpaceRunnableHandler.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/scripts/handler/impl/RestDSpaceRunnableHandler.java @@ -130,7 +130,7 @@ public class RestDSpaceRunnableHandler implements DSpaceRunnableHandler { @Override public void handleException(Exception e) { - handleException(null, e); + handleException(e.getMessage(), e); } @Override diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/SamlLoginFilter.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/SamlLoginFilter.java new file mode 100644 index 0000000000..2775220689 --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/SamlLoginFilter.java @@ -0,0 +1,119 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.app.rest.security; + +import java.io.IOException; +import java.util.Arrays; +import java.util.stream.Stream; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.dspace.authenticate.SamlAuthentication; +import org.dspace.core.Utils; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderNotFoundException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; + +/** + * A filter that examines requests to see if the user has been authenticated via SAML. + *

+ * The overall SAML login process is as follows: + *

+ *
    + *
  1. When SAML authentication is enabled, the client/UI receives the URL to the active SAML + * relying party's authentication endpoint in the WWW-Authenticate header. + * See {@link org.dspace.authenticate.SamlAuthentication#loginPageURL(org.dspace.core.Context, HttpServletRequest, HttpServletResponse)}.
  2. + *
  3. The client sends the user to that URL when they select SAML authentication.
  4. + *
  5. The active SAML relying party sends the client to the login page at the asserting party + * (aka identity provider, or IdP).
  6. + *
  7. The user logs in to the asserting party.
  8. + *
  9. If successful, the asserting party sends the client back to the relying party's assertion + * consumer endpoint, along with the SAML assertion.
  10. + *
  11. The relying party receives the SAML assertion, extracts attributes from the assertion, + * maps them into request attributes, and forwards the request to the path where this filter + * is listening.
  12. + *
  13. This filter intercepts the request in order to check for a valid SAML login (see + * {@link org.dspace.authenticate.SamlAuthentication#authenticate(org.dspace.core.Context, String, String, String, HttpServletRequest)}) + * and stores that user info in a JWT. It also saves that JWT in a temporary + * authentication cookie.
  14. + *
  15. This filter redirects the user back to the UI (after verifying it's at a trusted URL).
  16. + *
  17. The client reads the JWT from the cookie, and sends it back in a request to + * /api/authn/login, which triggers the server-side to destroy the cookie and move the JWT + * into a header.
  18. + *
+ * + * @author Ray Lee + */ +public class SamlLoginFilter extends StatelessLoginFilter { + private static final Logger logger = LogManager.getLogger(SamlLoginFilter.class); + + private ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + + public SamlLoginFilter(String url, String httpMethod, AuthenticationManager authenticationManager, + RestAuthenticationService restAuthenticationService) { + super(url, httpMethod, authenticationManager, restAuthenticationService); + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException { + + if (!SamlAuthentication.isEnabled()) { + throw new ProviderNotFoundException("SAML is disabled."); + } + + // Because this authentication is implicit, we pass in an empty DSpaceAuthentication. + return authenticationManager.authenticate(new DSpaceAuthentication()); + } + + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, + Authentication auth) throws IOException, ServletException { + + restAuthenticationService.addAuthenticationDataForUser(request, response, (DSpaceAuthentication) auth, true); + + redirectAfterSuccess(request, response); + } + + /** + * After successful login, redirect to the configured UI URL. If that URL is not allowed for + * this DSpace site, return a 400 error. + * + * @param request + * @param response + * @throws IOException + */ + private void redirectAfterSuccess(HttpServletRequest request, HttpServletResponse response) throws IOException { + String redirectUrl = configurationService.getProperty("dspace.ui.url"); + String redirectHostName = Utils.getHostName(redirectUrl); + String serverUrl = configurationService.getProperty("dspace.server.url"); + + boolean isRedirectAllowed = Stream.concat( + Stream.of(serverUrl), + Arrays.stream(configurationService.getArrayProperty("rest.cors.allowed-origins"))) + .map(url -> Utils.getHostName(url)) + .anyMatch(hostName -> hostName.equalsIgnoreCase(redirectHostName)); + + if (isRedirectAllowed) { + logger.debug("SAML redirecting to " + redirectUrl); + + response.sendRedirect(redirectUrl); + } else { + logger.error("SAML redirect URL {} is not allowed" + redirectUrl); + + response.sendError(HttpServletResponse.SC_BAD_REQUEST,"SAML redirect URL not allowed"); + } + } +} diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/WebSecurityConfiguration.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/WebSecurityConfiguration.java index af7116a2be..250af8fa06 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/WebSecurityConfiguration.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/WebSecurityConfiguration.java @@ -161,6 +161,12 @@ public class WebSecurityConfiguration { .addFilterBefore(new OidcLoginFilter("/api/authn/oidc", HttpMethod.GET.name(), authenticationManager, restAuthenticationService), LogoutFilter.class) + // Add a filter before our SAML endpoints to do the authentication based on the data in the HTTP request. + // This endpoint only responds to GET as the actual authentication is performed by SAML, which then + // forwards to this endpoint to pass the authentication data to DSpace. + .addFilterBefore(new SamlLoginFilter("/api/authn/saml", HttpMethod.GET.name(), + authenticationManager, restAuthenticationService), + LogoutFilter.class) // Add a custom Token based authentication filter based on the token previously given to the client // before each URL .addFilterBefore(new StatelessAuthenticationFilter(authenticationManager, restAuthenticationService, diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/jwt/JWTTokenRestAuthenticationServiceImpl.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/jwt/JWTTokenRestAuthenticationServiceImpl.java index a2928fc96f..4828dc25bb 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/jwt/JWTTokenRestAuthenticationServiceImpl.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/jwt/JWTTokenRestAuthenticationServiceImpl.java @@ -245,7 +245,11 @@ public class JWTTokenRestAuthenticationServiceImpl implements RestAuthentication // which destroys this temporary auth cookie. So, the auth cookie only exists a few seconds. if (addCookie) { ResponseCookie cookie = ResponseCookie.from(AUTHORIZATION_COOKIE, token) - .httpOnly(true).secure(true).sameSite("None").build(); + .httpOnly(true) + .secure(true) + .sameSite("None") + .path("/server/api/authn") + .build(); // Write the cookie to the Set-Cookie header in order to send it response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/bitstream/BitstreamLinksetProcessor.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/bitstream/BitstreamLinksetProcessor.java index 97eb9f2a54..349dde7b6d 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/bitstream/BitstreamLinksetProcessor.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/bitstream/BitstreamLinksetProcessor.java @@ -10,7 +10,8 @@ package org.dspace.app.rest.signposting.processor.bitstream; import java.util.List; import jakarta.servlet.http.HttpServletRequest; -import org.apache.log4j.Logger; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.dspace.app.rest.signposting.model.LinksetNode; import org.dspace.app.rest.signposting.model.LinksetRelationType; import org.dspace.content.Bitstream; @@ -25,7 +26,7 @@ import org.dspace.util.FrontendUrlService; */ public class BitstreamLinksetProcessor extends BitstreamSignpostingProcessor { - private static final Logger log = Logger.getLogger(BitstreamLinksetProcessor.class); + private static final Logger log = LogManager.getLogger(BitstreamLinksetProcessor.class); private final BitstreamService bitstreamService; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/bitstream/BitstreamParentItemProcessor.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/bitstream/BitstreamParentItemProcessor.java index 32928dfa88..c855f06784 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/bitstream/BitstreamParentItemProcessor.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/bitstream/BitstreamParentItemProcessor.java @@ -10,7 +10,8 @@ package org.dspace.app.rest.signposting.processor.bitstream; import java.util.List; import jakarta.servlet.http.HttpServletRequest; -import org.apache.log4j.Logger; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.dspace.app.rest.signposting.model.LinksetNode; import org.dspace.app.rest.signposting.model.LinksetRelationType; import org.dspace.content.Bitstream; @@ -28,7 +29,7 @@ import org.dspace.util.FrontendUrlService; */ public class BitstreamParentItemProcessor extends BitstreamSignpostingProcessor { - private static final Logger log = Logger.getLogger(BitstreamParentItemProcessor.class); + private static final Logger log = LogManager.getLogger(BitstreamParentItemProcessor.class); private final BitstreamService bitstreamService; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/bitstream/BitstreamTypeProcessor.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/bitstream/BitstreamTypeProcessor.java index 8889a415d3..d0f170b4c5 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/bitstream/BitstreamTypeProcessor.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/bitstream/BitstreamTypeProcessor.java @@ -11,7 +11,8 @@ import java.util.List; import jakarta.servlet.http.HttpServletRequest; import org.apache.commons.lang3.StringUtils; -import org.apache.log4j.Logger; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.dspace.app.rest.signposting.model.LinksetNode; import org.dspace.app.rest.signposting.model.LinksetRelationType; import org.dspace.content.Bitstream; @@ -28,7 +29,7 @@ import org.springframework.beans.factory.annotation.Autowired; */ public class BitstreamTypeProcessor extends BitstreamSignpostingProcessor { - private static final Logger log = Logger.getLogger(BitstreamTypeProcessor.class); + private static final Logger log = LogManager.getLogger(BitstreamTypeProcessor.class); @Autowired private SimpleMapConverter mapConverterDSpaceToSchemaOrgUri; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemAuthorProcessor.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemAuthorProcessor.java index f3a9e35198..ebc7c46f4d 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemAuthorProcessor.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemAuthorProcessor.java @@ -16,7 +16,8 @@ import java.text.MessageFormat; import java.util.List; import jakarta.servlet.http.HttpServletRequest; -import org.apache.log4j.Logger; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.dspace.app.rest.signposting.model.LinksetNode; import org.dspace.app.rest.signposting.model.LinksetRelationType; import org.dspace.content.Item; @@ -37,7 +38,7 @@ public class ItemAuthorProcessor extends ItemSignpostingProcessor { /** * log4j category */ - private static final Logger log = Logger.getLogger(ItemAuthorProcessor.class); + private static final Logger log = LogManager.getLogger(ItemAuthorProcessor.class); private final ItemService itemService; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemContentBitstreamsProcessor.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemContentBitstreamsProcessor.java index 0b91e57f7b..65a29c2b6c 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemContentBitstreamsProcessor.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemContentBitstreamsProcessor.java @@ -11,7 +11,8 @@ import java.sql.SQLException; import java.util.List; import jakarta.servlet.http.HttpServletRequest; -import org.apache.log4j.Logger; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.dspace.app.rest.signposting.model.LinksetNode; import org.dspace.app.rest.signposting.model.LinksetRelationType; import org.dspace.content.Bitstream; @@ -33,7 +34,7 @@ public class ItemContentBitstreamsProcessor extends ItemSignpostingProcessor { /** * log4j category */ - private static final Logger log = Logger.getLogger(ItemContentBitstreamsProcessor.class); + private static final Logger log = LogManager.getLogger(ItemContentBitstreamsProcessor.class); public ItemContentBitstreamsProcessor(FrontendUrlService frontendUrlService) { super(frontendUrlService); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemDescribedbyProcessor.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemDescribedbyProcessor.java index 20091e6d09..a86b98f2e5 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemDescribedbyProcessor.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemDescribedbyProcessor.java @@ -10,7 +10,8 @@ package org.dspace.app.rest.signposting.processor.item; import java.util.List; import jakarta.servlet.http.HttpServletRequest; -import org.apache.log4j.Logger; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.dspace.app.rest.signposting.model.LinksetNode; import org.dspace.app.rest.signposting.model.LinksetRelationType; import org.dspace.content.Item; @@ -23,7 +24,7 @@ import org.dspace.util.FrontendUrlService; */ public class ItemDescribedbyProcessor extends ItemSignpostingProcessor { - private static final Logger log = Logger.getLogger(ItemDescribedbyProcessor.class); + private static final Logger log = LogManager.getLogger(ItemDescribedbyProcessor.class); private final ConfigurationService configurationService; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemLicenseProcessor.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemLicenseProcessor.java index b60ee35d7f..6e26d8f1b2 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemLicenseProcessor.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemLicenseProcessor.java @@ -11,7 +11,8 @@ import java.util.List; import jakarta.servlet.http.HttpServletRequest; import org.apache.commons.lang3.StringUtils; -import org.apache.log4j.Logger; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.dspace.app.rest.signposting.model.LinksetNode; import org.dspace.app.rest.signposting.model.LinksetRelationType; import org.dspace.content.Item; @@ -25,7 +26,7 @@ import org.dspace.util.FrontendUrlService; */ public class ItemLicenseProcessor extends ItemSignpostingProcessor { - private static final Logger log = Logger.getLogger(ItemLicenseProcessor.class); + private static final Logger log = LogManager.getLogger(ItemLicenseProcessor.class); private final CreativeCommonsService creativeCommonsService = LicenseServiceFactory.getInstance().getCreativeCommonsService(); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemLinksetProcessor.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemLinksetProcessor.java index 2d09e56161..4e48caf959 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemLinksetProcessor.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemLinksetProcessor.java @@ -10,7 +10,8 @@ package org.dspace.app.rest.signposting.processor.item; import java.util.List; import jakarta.servlet.http.HttpServletRequest; -import org.apache.log4j.Logger; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.dspace.app.rest.signposting.model.LinksetNode; import org.dspace.app.rest.signposting.model.LinksetRelationType; import org.dspace.content.Item; @@ -23,7 +24,7 @@ import org.dspace.util.FrontendUrlService; */ public class ItemLinksetProcessor extends ItemSignpostingProcessor { - private static final Logger log = Logger.getLogger(ItemLinksetProcessor.class); + private static final Logger log = LogManager.getLogger(ItemLinksetProcessor.class); private final ConfigurationService configurationService; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemTypeProcessor.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemTypeProcessor.java index 49b3612cd9..f2533a5a95 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemTypeProcessor.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/processor/item/ItemTypeProcessor.java @@ -11,7 +11,8 @@ import java.util.List; import jakarta.servlet.http.HttpServletRequest; import org.apache.commons.lang3.StringUtils; -import org.apache.log4j.Logger; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.dspace.app.rest.signposting.model.LinksetNode; import org.dspace.app.rest.signposting.model.LinksetRelationType; import org.dspace.content.Item; @@ -27,7 +28,7 @@ import org.springframework.beans.factory.annotation.Autowired; */ public class ItemTypeProcessor extends ItemSignpostingProcessor { - private static final Logger log = Logger.getLogger(ItemTypeProcessor.class); + private static final Logger log = LogManager.getLogger(ItemTypeProcessor.class); private static final String ABOUT_PAGE_URI = "https://schema.org/AboutPage"; @Autowired diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/service/impl/LinksetServiceImpl.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/service/impl/LinksetServiceImpl.java index 5b28817c94..42b1c81849 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/service/impl/LinksetServiceImpl.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/signposting/service/impl/LinksetServiceImpl.java @@ -13,7 +13,8 @@ import java.util.Iterator; import java.util.List; import jakarta.servlet.http.HttpServletRequest; -import org.apache.log4j.Logger; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.dspace.app.rest.security.BitstreamMetadataReadPermissionEvaluatorPlugin; import org.dspace.app.rest.signposting.model.LinksetNode; import org.dspace.app.rest.signposting.processor.bitstream.BitstreamSignpostingProcessor; @@ -37,7 +38,7 @@ import org.springframework.stereotype.Service; @Service public class LinksetServiceImpl implements LinksetService { - private static final Logger log = Logger.getLogger(LinksetServiceImpl.class); + private static final Logger log = LogManager.getLogger(LinksetServiceImpl.class); @Autowired protected ItemService itemService; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/validation/CclicenseValidator.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/validation/CclicenseValidator.java index d0cfd14ec8..2273b377fe 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/validation/CclicenseValidator.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/validation/CclicenseValidator.java @@ -12,8 +12,8 @@ import static org.dspace.app.rest.repository.WorkspaceItemRestRepository.OPERATI import java.sql.SQLException; import java.util.ArrayList; import java.util.List; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.dspace.app.rest.model.ErrorRest; import org.dspace.app.rest.submit.SubmissionService; import org.dspace.app.util.DCInputsReaderException; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/BitstreamResource.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/BitstreamResource.java index 4e5545fabc..1ac6a320d9 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/BitstreamResource.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/BitstreamResource.java @@ -15,7 +15,8 @@ import java.util.Set; import java.util.UUID; import org.apache.commons.io.IOUtils; -import org.apache.commons.lang3.tuple.Pair; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.dspace.authorize.AuthorizeException; import org.dspace.content.Bitstream; import org.dspace.content.factory.ContentServiceFactory; @@ -27,6 +28,7 @@ import org.dspace.eperson.factory.EPersonServiceFactory; import org.dspace.eperson.service.EPersonService; import org.dspace.utils.DSpace; import org.springframework.core.io.AbstractResource; +import org.springframework.util.DigestUtils; /** * This class acts as a {@link AbstractResource} used by Spring's framework to send the data in a proper and @@ -36,21 +38,24 @@ import org.springframework.core.io.AbstractResource; */ public class BitstreamResource extends AbstractResource { - private String name; - private UUID uuid; - private UUID currentUserUUID; - private boolean shouldGenerateCoverPage; - private byte[] file; - private Set currentSpecialGroups; + private static final Logger LOG = LogManager.getLogger(BitstreamResource.class); - private BitstreamService bitstreamService = ContentServiceFactory.getInstance().getBitstreamService(); - private EPersonService ePersonService = EPersonServiceFactory.getInstance().getEPersonService(); - private CitationDocumentService citationDocumentService = - new DSpace().getServiceManager() + private final String name; + private final UUID uuid; + private final UUID currentUserUUID; + private final boolean shouldGenerateCoverPage; + private final Set currentSpecialGroups; + + private final BitstreamService bitstreamService = ContentServiceFactory.getInstance().getBitstreamService(); + private final EPersonService ePersonService = EPersonServiceFactory.getInstance().getEPersonService(); + private final CitationDocumentService citationDocumentService = + new DSpace().getServiceManager() .getServicesByType(CitationDocumentService.class).get(0); + private BitstreamDocument document; + public BitstreamResource(String name, UUID uuid, UUID currentUserUUID, Set currentSpecialGroups, - boolean shouldGenerateCoverPage) { + boolean shouldGenerateCoverPage) { this.name = name; this.uuid = uuid; this.currentUserUUID = currentUserUUID; @@ -67,17 +72,15 @@ public class BitstreamResource extends AbstractResource { * @return a byte array containing the cover page */ private byte[] getCoverpageByteArray(Context context, Bitstream bitstream) - throws IOException, SQLException, AuthorizeException { - if (file == null) { - try { - Pair citedDocument = citationDocumentService.makeCitedDocument(context, bitstream); - this.file = citedDocument.getLeft(); - } catch (Exception e) { - // Return the original bitstream without the cover page - this.file = IOUtils.toByteArray(bitstreamService.retrieve(context, bitstream)); - } + throws IOException, SQLException, AuthorizeException { + try { + var citedDocument = citationDocumentService.makeCitedDocument(context, bitstream); + return citedDocument.getLeft(); + } catch (Exception e) { + LOG.warn("Could not generate cover page. Will fallback to original document", e); + // Return the original bitstream without the cover page + return IOUtils.toByteArray(bitstreamService.retrieve(context, bitstream)); } - return file; } @Override @@ -87,22 +90,9 @@ public class BitstreamResource extends AbstractResource { @Override public InputStream getInputStream() throws IOException { - try (Context context = initializeContext()) { + fetchDocument(); - Bitstream bitstream = bitstreamService.find(context, uuid); - InputStream out; - - if (shouldGenerateCoverPage) { - out = new ByteArrayInputStream(getCoverpageByteArray(context, bitstream)); - } else { - out = bitstreamService.retrieve(context, bitstream); - } - - this.file = null; - return out; - } catch (SQLException | AuthorizeException e) { - throw new IOException(e); - } + return document.inputStream(); } @Override @@ -111,17 +101,60 @@ public class BitstreamResource extends AbstractResource { } @Override - public long contentLength() throws IOException { + public long contentLength() { + fetchDocument(); + + return document.length(); + } + + public String getChecksum() { + fetchDocument(); + + return document.etag(); + } + + private void fetchDocument() { + if (document != null) { + return; + } + try (Context context = initializeContext()) { Bitstream bitstream = bitstreamService.find(context, uuid); if (shouldGenerateCoverPage) { - return getCoverpageByteArray(context, bitstream).length; + var coverPage = getCoverpageByteArray(context, bitstream); + + this.document = new BitstreamDocument(etag(bitstream), + coverPage.length, + new ByteArrayInputStream(coverPage)); } else { - return bitstream.getSizeBytes(); + this.document = new BitstreamDocument(bitstream.getChecksum(), + bitstream.getSizeBytes(), + bitstreamService.retrieve(context, bitstream)); } - } catch (SQLException | AuthorizeException e) { - throw new IOException(e); + } catch (SQLException | AuthorizeException | IOException e) { + throw new RuntimeException(e); } + + LOG.debug("fetched document {} {}", shouldGenerateCoverPage, document); + } + + private String etag(Bitstream bitstream) { + + /* Ideally we would calculate the md5 checksum based on the document with coverpage. + However it looks like the coverpage generation is not stable (e.g. if invoked twice it will return + different results). This means we cannot use it for etag calculation/comparison! + + Instead we will create the MD5 based off the original checksum plus fixed prefix. This ensures + that checksums will differ when coverpage is on/off. + However the checksum will _not_ change if the coverpage content changes. + */ + + var content = "coverpage:" + bitstream.getChecksum(); + + StringBuilder builder = new StringBuilder(37); + DigestUtils.appendMd5DigestAsHex(content.getBytes(), builder); + + return builder.toString(); } private Context initializeContext() throws SQLException { @@ -131,4 +164,6 @@ public class BitstreamResource extends AbstractResource { currentSpecialGroups.forEach(context::setSpecialGroup); return context; } + + private record BitstreamDocument(String etag, long length, InputStream inputStream) {} } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/DSpaceKernelInitializer.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/DSpaceKernelInitializer.java index 7dfcd1d76d..88a093c057 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/DSpaceKernelInitializer.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/DSpaceKernelInitializer.java @@ -81,6 +81,7 @@ public class DSpaceKernelInitializer implements ApplicationContextInitializer - +