diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1749c28fe29..3b9311abeca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -53,11 +53,11 @@ jobs: steps: # https://github.com/actions/checkout - name: Checkout codebase - uses: actions/checkout@v4 + uses: actions/checkout@v6 # https://github.com/actions/setup-java - name: Install JDK ${{ matrix.java }} - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: java-version: ${{ matrix.java }} distribution: 'temurin' @@ -73,14 +73,14 @@ jobs: # (This artifact is downloadable at the bottom of any job's summary page) - name: Upload Results of ${{ matrix.type }} to Artifact if: ${{ failure() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.type }} results path: ${{ matrix.resultsdir }} # Upload code coverage report to artifact, so that it can be shared with the 'codecov' job (see below) - name: Upload code coverage report to Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.type }} coverage report path: 'dspace/target/site/jacoco-aggregate/jacoco.xml' diff --git a/.github/workflows/codescan.yml b/.github/workflows/codescan.yml index 720a0cadf1a..a1c3833dd40 100644 --- a/.github/workflows/codescan.yml +++ b/.github/workflows/codescan.yml @@ -31,11 +31,11 @@ jobs: steps: # https://github.com/actions/checkout - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 # https://github.com/actions/setup-java - name: Install JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: java-version: 11 distribution: 'temurin' @@ -43,7 +43,7 @@ jobs: # Initializes the CodeQL tools for scanning. # https://github.com/github/codeql-action - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v4 with: # Codescan Javascript as well since a few JS files exist in REST API's interface languages: java, javascript @@ -52,8 +52,8 @@ jobs: # NOTE: Based on testing, this autobuild process works well for DSpace. A custom # DSpace build w/caching (like in build.yml) was about the same speed as autobuild. - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v4 # Perform GitHub Code Scanning. - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/port_merged_pull_request.yml b/.github/workflows/port_merged_pull_request.yml index 857f22755e4..676ad45ba26 100644 --- a/.github/workflows/port_merged_pull_request.yml +++ b/.github/workflows/port_merged_pull_request.yml @@ -23,11 +23,11 @@ jobs: if: github.event.pull_request.merged steps: # Checkout code - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # Port PR to other branch (ONLY if labeled with "port to") # See https://github.com/korthout/backport-action - name: Create backport pull requests - uses: korthout/backport-action@v2 + uses: korthout/backport-action@v4 with: # Trigger based on a "port to [branch]" label on PR # (This label must specify the branch name to port to) diff --git a/.github/workflows/pull_request_opened.yml b/.github/workflows/pull_request_opened.yml index bbac52af243..e2b6e8ba9c2 100644 --- a/.github/workflows/pull_request_opened.yml +++ b/.github/workflows/pull_request_opened.yml @@ -21,4 +21,4 @@ jobs: # Assign the PR to whomever created it. This is useful for visualizing assignments on project boards # See https://github.com/toshimaru/auto-author-assign - name: Assign PR to creator - uses: toshimaru/auto-author-assign@v2.1.0 + uses: toshimaru/auto-author-assign@v3.0.1 diff --git a/.github/workflows/reusable-docker-build.yml b/.github/workflows/reusable-docker-build.yml index 2e37477bee6..a45c973b977 100644 --- a/.github/workflows/reusable-docker-build.yml +++ b/.github/workflows/reusable-docker-build.yml @@ -105,7 +105,7 @@ jobs: steps: # https://github.com/actions/checkout - name: Checkout codebase - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Add version if: ${{ inputs.run_python_version_script }} @@ -113,7 +113,7 @@ jobs: # https://github.com/docker/setup-buildx-action - name: Setup Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 # https://github.com/docker/setup-qemu-action - name: Set up QEMU emulation to build for multiple architectures @@ -132,7 +132,7 @@ jobs: # Get Metadata for docker_build_deps step below - name: Sync metadata (tags, labels) from GitHub to Docker for image id: meta_build - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.IMAGE_NAME }} tags: ${{ env.IMAGE_TAGS }} @@ -141,7 +141,7 @@ jobs: # https://github.com/docker/build-push-action - name: Build and push image id: docker_build - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: build-contexts: | ${{ inputs.dockerfile_additional_contexts }} @@ -154,6 +154,10 @@ jobs: # 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=min # Export the digest of Docker build locally (for non PRs only) # - name: Export Docker build digest @@ -236,4 +240,4 @@ jobs: # - name: Inspect image # run: | - # docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} \ No newline at end of file + # docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} diff --git a/Dockerfile b/Dockerfile index c4a453c42c1..3da85193fe8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,16 +38,11 @@ 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 -# Download and install 'ant' -RUN mkdir $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 +# Install Apache Ant +RUN apt-get update \ + && apt-get install -y --no-install-recommends ant \ + && apt-get purge -y --auto-remove \ + && rm -rf /var/lib/apt/lists/* # Run necessary 'ant' deploy scripts RUN ant init_installation update_configs update_code update_webapps diff --git a/Dockerfile.cli b/Dockerfile.cli index 4c55629f0b5..a765aa43351 100644 --- a/Dockerfile.cli +++ b/Dockerfile.cli @@ -34,16 +34,11 @@ 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 -# Download and install 'ant' -RUN mkdir $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 +# Install Apache Ant +RUN apt-get update \ + && apt-get install -y --no-install-recommends ant \ + && apt-get purge -y --auto-remove \ + && rm -rf /var/lib/apt/lists/* # Run necessary 'ant' deploy scripts RUN ant init_installation update_configs update_code diff --git a/Dockerfile.test b/Dockerfile.test index 65e9cb956a4..8d14834a9bc 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -37,16 +37,11 @@ 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 -# Download and install 'ant' -RUN mkdir $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 +# Install Apache Ant +RUN apt-get update \ + && apt-get install -y --no-install-recommends ant \ + && apt-get purge -y --auto-remove \ + && rm -rf /var/lib/apt/lists/* # Run necessary 'ant' deploy scripts RUN ant init_installation update_configs update_code update_webapps diff --git a/LICENSES_THIRD_PARTY b/LICENSES_THIRD_PARTY index 92868467c98..fdb56efc360 100644 --- a/LICENSES_THIRD_PARTY +++ b/LICENSES_THIRD_PARTY @@ -21,19 +21,14 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines Apache Software License, Version 2.0: * Ant-Contrib Tasks (ant-contrib:ant-contrib:1.0b3 - http://ant-contrib.sourceforge.net) - * AWS SDK for Java - Core (com.amazonaws:aws-java-sdk-core:1.12.785 - https://aws.amazon.com/sdkforjava) - * AWS Java SDK for AWS KMS (com.amazonaws:aws-java-sdk-kms:1.12.785 - https://aws.amazon.com/sdkforjava) - * AWS Java SDK for Amazon S3 (com.amazonaws:aws-java-sdk-s3:1.12.785 - https://aws.amazon.com/sdkforjava) - * JMES Path Query library (com.amazonaws:jmespath-java:1.12.785 - https://aws.amazon.com/sdkforjava) * HPPC Collections (com.carrotsearch:hppc:0.8.1 - http://labs.carrotsearch.com/hppc.html/hppc) * com.drewnoakes:metadata-extractor (com.drewnoakes:metadata-extractor:2.19.0 - https://drewnoakes.com/code/exif/) * parso (com.epam:parso:2.0.14 - https://github.com/epam/parso) * Internet Time Utility (com.ethlo.time:itu:1.7.0 - https://github.com/ethlo/itu) - * ClassMate (com.fasterxml:classmate:1.7.0 - https://github.com/FasterXML/java-classmate) - * Jackson-annotations (com.fasterxml.jackson.core:jackson-annotations:2.19.1 - https://github.com/FasterXML/jackson) - * Jackson-core (com.fasterxml.jackson.core:jackson-core:2.19.1 - https://github.com/FasterXML/jackson-core) - * jackson-databind (com.fasterxml.jackson.core:jackson-databind:2.19.1 - https://github.com/FasterXML/jackson) - * Jackson dataformat: CBOR (com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.17.2 - https://github.com/FasterXML/jackson-dataformats-binary) + * ClassMate (com.fasterxml:classmate:1.7.3 - https://github.com/FasterXML/java-classmate) + * Jackson-annotations (com.fasterxml.jackson.core:jackson-annotations:2.21 - https://github.com/FasterXML/jackson) + * Jackson-core (com.fasterxml.jackson.core:jackson-core:2.21.2 - https://github.com/FasterXML/jackson-core) + * jackson-databind (com.fasterxml.jackson.core:jackson-databind:2.21.2 - https://github.com/FasterXML/jackson) * Jackson dataformat: Smile (com.fasterxml.jackson.dataformat:jackson-dataformat-smile:2.15.2 - https://github.com/FasterXML/jackson-dataformats-binary) * Jackson-dataformat-YAML (com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.14.0 - https://github.com/FasterXML/jackson-dataformats-text) * Jackson datatype: jdk8 (com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.13.5 - https://github.com/FasterXML/jackson-modules-java8/jackson-datatype-jdk8) @@ -45,6 +40,9 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines * zjsonpatch (com.flipkart.zjsonpatch:zjsonpatch:0.4.16 - https://github.com/flipkart-incubator/zjsonpatch/) * Caffeine cache (com.github.ben-manes.caffeine:caffeine:2.9.3 - https://github.com/ben-manes/caffeine) * JSON.simple (com.github.cliftonlabs:json-simple:3.0.2 - https://cliftonlabs.github.io/json-simple/) + * docker-java-api (com.github.docker-java:docker-java-api:3.7.1 - https://github.com/docker-java/docker-java) + * docker-java-transport (com.github.docker-java:docker-java-transport:3.7.1 - https://github.com/docker-java/docker-java) + * docker-java-transport-zerodep (com.github.docker-java:docker-java-transport-zerodep:3.7.1 - https://github.com/docker-java/docker-java) * btf (com.github.java-json-tools:btf:1.3 - https://github.com/java-json-tools/btf) * jackson-coreutils (com.github.java-json-tools:jackson-coreutils:2.0 - https://github.com/java-json-tools/jackson-coreutils) * jackson-coreutils-equivalence (com.github.java-json-tools:jackson-coreutils-equivalence:1.0 - https://github.com/java-json-tools/jackson-coreutils) @@ -58,30 +56,31 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines * Google Analytics API v3-rev145-1.23.0 (com.google.apis:google-api-services-analytics:v3-rev145-1.23.0 - http://nexus.sonatype.org/oss-repository-hosting.html/google-api-services-analytics) * FindBugs-jsr305 (com.google.code.findbugs:jsr305:3.0.2 - http://findbugs.sourceforge.net/) * Gson (com.google.code.gson:gson:2.11.0 - https://github.com/google/gson) - * error-prone annotations (com.google.errorprone:error_prone_annotations:2.21.1 - https://errorprone.info/error_prone_annotations) + * error-prone annotations (com.google.errorprone:error_prone_annotations:2.47.0 - https://errorprone.info/error_prone_annotations) * Guava InternalFutureFailureAccess and InternalFutures (com.google.guava:failureaccess:1.0.1 - https://github.com/google/guava/failureaccess) - * Guava: Google Core Libraries for Java (com.google.guava:guava:32.1.3-jre - https://github.com/google/guava) + * Guava InternalFutureFailureAccess and InternalFutures (com.google.guava:failureaccess:1.0.3 - https://github.com/google/guava/failureaccess) + * Guava: Google Core Libraries for Java (com.google.guava:guava:33.6.0-jre - https://github.com/google/guava) * Guava ListenableFuture only (com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava - https://github.com/google/guava/listenablefuture) - * Google HTTP Client Library for Java (com.google.http-client:google-http-client:1.47.0 - https://github.com/googleapis/google-http-java-client/google-http-client) + * Google HTTP Client Library for Java (com.google.http-client:google-http-client:1.47.1 - https://github.com/googleapis/google-http-java-client/google-http-client) * Apache HTTP transport v2 for the Google HTTP Client Library for Java. (com.google.http-client:google-http-client-apache-v2:1.42.0 - https://github.com/googleapis/google-http-java-client/google-http-client-apache-v2) - * GSON extensions to the Google HTTP Client Library for Java. (com.google.http-client:google-http-client-gson:1.47.0 - https://github.com/googleapis/google-http-java-client/google-http-client-gson) - * Jackson 2 extensions to the Google HTTP Client Library for Java. (com.google.http-client:google-http-client-jackson2:1.47.0 - https://github.com/googleapis/google-http-java-client/google-http-client-jackson2) + * GSON extensions to the Google HTTP Client Library for Java. (com.google.http-client:google-http-client-gson:1.47.1 - https://github.com/googleapis/google-http-java-client/google-http-client-gson) + * Jackson 2 extensions to the Google HTTP Client Library for Java. (com.google.http-client:google-http-client-jackson2:1.47.1 - https://github.com/googleapis/google-http-java-client/google-http-client-jackson2) * J2ObjC Annotations (com.google.j2objc:j2objc-annotations:1.3 - https://github.com/google/j2objc/) - * J2ObjC Annotations (com.google.j2objc:j2objc-annotations:2.8 - https://github.com/google/j2objc/) + * J2ObjC Annotations (com.google.j2objc:j2objc-annotations:3.1 - https://github.com/google/j2objc/) * Google OAuth Client Library for Java (com.google.oauth-client:google-oauth-client:1.39.0 - https://github.com/googleapis/google-oauth-java-client/google-oauth-client) * ConcurrentLinkedHashMap (com.googlecode.concurrentlinkedhashmap:concurrentlinkedhashmap-lru:1.4.2 - http://code.google.com/p/concurrentlinkedhashmap) * libphonenumber (com.googlecode.libphonenumber:libphonenumber:8.11.1 - https://github.com/google/libphonenumber/) - * Jackcess (com.healthmarketscience.jackcess:jackcess:4.0.8 - https://jackcess.sourceforge.io) + * Jackcess (com.healthmarketscience.jackcess:jackcess:4.0.10 - https://jackcess.sourceforge.io) * Jackcess Encrypt (com.healthmarketscience.jackcess:jackcess-encrypt:4.0.3 - http://jackcessencrypt.sf.net) - * json-path (com.jayway.jsonpath:json-path:2.9.0 - https://github.com/jayway/JsonPath) - * json-path-assert (com.jayway.jsonpath:json-path-assert:2.9.0 - https://github.com/jayway/JsonPath) + * json-path (com.jayway.jsonpath:json-path:2.10.0 - https://github.com/jayway/JsonPath) + * json-path-assert (com.jayway.jsonpath:json-path-assert:2.10.0 - https://github.com/jayway/JsonPath) * Disruptor Framework (com.lmax:disruptor:3.4.2 - http://lmax-exchange.github.com/disruptor) * MaxMind DB Reader (com.maxmind.db:maxmind-db:2.1.0 - http://dev.maxmind.com/) * MaxMind GeoIP2 API (com.maxmind.geoip2:geoip2:2.17.0 - https://dev.maxmind.com/geoip?lang=en) * JsonSchemaValidator (com.networknt:json-schema-validator:1.0.76 - https://github.com/networknt/json-schema-validator) * Nimbus JOSE+JWT (com.nimbusds:nimbus-jose-jwt:7.9 - https://bitbucket.org/connect2id/nimbus-jose-jwt) * Nimbus JOSE+JWT (com.nimbusds:nimbus-jose-jwt:9.28 - https://bitbucket.org/connect2id/nimbus-jose-jwt) - * opencsv (com.opencsv:opencsv:5.11.1 - http://opencsv.sf.net) + * opencsv (com.opencsv:opencsv:5.12.0 - http://opencsv.sf.net) * java-libpst (com.pff:java-libpst:0.9.3 - https://github.com/rjohnsondev/java-libpst) * rome (com.rometools:rome:1.19.0 - http://rometools.com/rome) * rome-modules (com.rometools:rome-modules:1.19.0 - http://rometools.com/rome-modules) @@ -91,27 +90,18 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines * okio (com.squareup.okio:okio:3.6.0 - https://github.com/square/okio/) * okio (com.squareup.okio:okio-jvm:3.6.0 - https://github.com/square/okio/) * T-Digest (com.tdunning:t-digest:3.1 - https://github.com/tdunning/t-digest) - * config (com.typesafe:config:1.3.3 - https://github.com/lightbend/config) - * ssl-config-core (com.typesafe:ssl-config-core_2.13:0.3.8 - https://github.com/lightbend/ssl-config) - * akka-actor (com.typesafe.akka:akka-actor_2.13:2.5.31 - https://akka.io/) - * akka-http-core (com.typesafe.akka:akka-http-core_2.13:10.1.12 - https://akka.io) - * akka-http (com.typesafe.akka:akka-http_2.13:10.1.12 - https://akka.io) - * akka-parsing (com.typesafe.akka:akka-parsing_2.13:10.1.12 - https://akka.io) - * akka-protobuf (com.typesafe.akka:akka-protobuf_2.13:2.5.31 - https://akka.io/) - * akka-stream (com.typesafe.akka:akka-stream_2.13:2.5.31 - https://akka.io/) - * scala-logging (com.typesafe.scala-logging:scala-logging_2.13:3.9.2 - https://github.com/lightbend/scala-logging) * JSON library from Android SDK (com.vaadin.external.google:android-json:0.0.20131108.vaadin1 - http://developer.android.com/sdk) * SparseBitSet (com.zaxxer:SparseBitSet:1.3 - https://github.com/brettwooldridge/SparseBitSet) * Apache Commons BeanUtils (commons-beanutils:commons-beanutils:1.11.0 - https://commons.apache.org/proper/commons-beanutils) - * Apache Commons CLI (commons-cli:commons-cli:1.9.0 - https://commons.apache.org/proper/commons-cli/) - * Apache Commons Codec (commons-codec:commons-codec:1.18.0 - https://commons.apache.org/proper/commons-codec/) + * Apache Commons CLI (commons-cli:commons-cli:1.11.0 - https://commons.apache.org/proper/commons-cli/) + * Apache Commons Codec (commons-codec:commons-codec:1.22.0 - https://commons.apache.org/proper/commons-codec/) * Apache Commons Collections (commons-collections:commons-collections:3.2.2 - http://commons.apache.org/collections/) * Commons Digester (commons-digester:commons-digester:2.1 - http://commons.apache.org/digester/) * Commons FileUpload (commons-fileupload:commons-fileupload:1.2.1 - http://commons.apache.org/fileupload/) - * Apache Commons IO (commons-io:commons-io:2.19.0 - https://commons.apache.org/proper/commons-io/) + * Apache Commons IO (commons-io:commons-io:2.22.0 - https://commons.apache.org/proper/commons-io/) * Commons Lang (commons-lang:commons-lang:2.6 - http://commons.apache.org/lang/) - * Apache Commons Logging (commons-logging:commons-logging:1.3.5 - https://commons.apache.org/proper/commons-logging/) - * Apache Commons Validator (commons-validator:commons-validator:1.9.0 - http://commons.apache.org/proper/commons-validator/) + * Apache Commons Logging (commons-logging:commons-logging:1.3.6 - https://commons.apache.org/proper/commons-logging/) + * Apache Commons Validator (commons-validator:commons-validator:1.10.1 - https://commons.apache.org/proper/commons-validator/) * GeoJson POJOs for Jackson (de.grundid.opendatalab:geojson-jackson:1.14 - https://github.com/opendatalab-de/geojson-jackson) * OpenAIRE Funders Model (eu.openaire:funders-model:2.0.0 - https://api.openaire.eu) * Metrics Core (io.dropwizard.metrics:metrics-core:4.1.5 - https://metrics.dropwizard.io/metrics-core) @@ -119,34 +109,34 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines * Metrics Integration for Jetty 9.3 and higher (io.dropwizard.metrics:metrics-jetty9:4.1.5 - https://metrics.dropwizard.io/metrics-jetty9) * Metrics Integration with JMX (io.dropwizard.metrics:metrics-jmx:4.1.5 - https://metrics.dropwizard.io/metrics-jmx) * JVM Integration for Metrics (io.dropwizard.metrics:metrics-jvm:4.1.5 - https://metrics.dropwizard.io/metrics-jvm) - * io.grpc:grpc-api (io.grpc:grpc-api:1.73.0 - https://github.com/grpc/grpc-java) - * io.grpc:grpc-context (io.grpc:grpc-context:1.73.0 - https://github.com/grpc/grpc-java) + * io.grpc:grpc-api (io.grpc:grpc-api:1.80.0 - https://github.com/grpc/grpc-java) + * io.grpc:grpc-context (io.grpc:grpc-context:1.80.0 - https://github.com/grpc/grpc-java) * micrometer-core (io.micrometer:micrometer-core:1.9.17 - https://github.com/micrometer-metrics/micrometer) * Netty/Buffer (io.netty:netty-buffer:4.1.99.Final - https://netty.io/netty-buffer/) - * Netty/Buffer (io.netty:netty-buffer:4.2.2.Final - https://netty.io/netty-buffer/) + * Netty/Buffer (io.netty:netty-buffer:4.2.12.Final - https://netty.io/netty-buffer/) * Netty/Codec (io.netty:netty-codec:4.1.99.Final - https://netty.io/netty-codec/) - * Netty/Codec (io.netty:netty-codec:4.2.2.Final - https://netty.io/netty-codec/) - * Netty/Codec/Base (io.netty:netty-codec-base:4.2.2.Final - https://netty.io/netty-codec-base/) - * Netty/Codec/Compression (io.netty:netty-codec-compression:4.2.2.Final - https://netty.io/netty-codec-compression/) + * Netty/Codec (io.netty:netty-codec:4.2.12.Final - https://netty.io/netty-codec/) + * Netty/Codec/Base (io.netty:netty-codec-base:4.2.12.Final - https://netty.io/netty-codec-base/) + * Netty/Codec/Compression (io.netty:netty-codec-compression:4.2.12.Final - https://netty.io/netty-codec-compression/) * Netty/Codec/HTTP (io.netty:netty-codec-http:4.1.86.Final - https://netty.io/netty-codec-http/) * Netty/Codec/HTTP2 (io.netty:netty-codec-http2:4.1.86.Final - https://netty.io/netty-codec-http2/) - * Netty/Codec/Marshalling (io.netty:netty-codec-marshalling:4.2.2.Final - https://netty.io/netty-codec-marshalling/) - * Netty/Codec/Protobuf (io.netty:netty-codec-protobuf:4.2.2.Final - https://netty.io/netty-codec-protobuf/) + * Netty/Codec/Marshalling (io.netty:netty-codec-marshalling:4.2.12.Final - https://netty.io/netty-codec-marshalling/) + * Netty/Codec/Protobuf (io.netty:netty-codec-protobuf:4.2.12.Final - https://netty.io/netty-codec-protobuf/) * Netty/Codec/Socks (io.netty:netty-codec-socks:4.1.86.Final - https://netty.io/netty-codec-socks/) * Netty/Common (io.netty:netty-common:4.1.99.Final - https://netty.io/netty-common/) - * Netty/Common (io.netty:netty-common:4.2.2.Final - https://netty.io/netty-common/) + * Netty/Common (io.netty:netty-common:4.2.12.Final - https://netty.io/netty-common/) * Netty/Handler (io.netty:netty-handler:4.1.99.Final - https://netty.io/netty-handler/) - * Netty/Handler (io.netty:netty-handler:4.2.2.Final - https://netty.io/netty-handler/) + * Netty/Handler (io.netty:netty-handler:4.2.12.Final - https://netty.io/netty-handler/) * Netty/Handler/Proxy (io.netty:netty-handler-proxy:4.1.86.Final - https://netty.io/netty-handler-proxy/) * Netty/Resolver (io.netty:netty-resolver:4.1.99.Final - https://netty.io/netty-resolver/) * Netty/TomcatNative [BoringSSL - Static] (io.netty:netty-tcnative-boringssl-static:2.0.56.Final - https://github.com/netty/netty-tcnative/netty-tcnative-boringssl-static/) * Netty/TomcatNative [OpenSSL - Classes] (io.netty:netty-tcnative-classes:2.0.56.Final - https://github.com/netty/netty-tcnative/netty-tcnative-classes/) * Netty/Transport (io.netty:netty-transport:4.1.99.Final - https://netty.io/netty-transport/) - * Netty/Transport (io.netty:netty-transport:4.2.2.Final - https://netty.io/netty-transport/) + * Netty/Transport (io.netty:netty-transport:4.2.12.Final - https://netty.io/netty-transport/) * Netty/Transport/Classes/Epoll (io.netty:netty-transport-classes-epoll:4.1.99.Final - https://netty.io/netty-transport-classes-epoll/) * Netty/Transport/Native/Epoll (io.netty:netty-transport-native-epoll:4.1.99.Final - https://netty.io/netty-transport-native-epoll/) * Netty/Transport/Native/Unix/Common (io.netty:netty-transport-native-unix-common:4.1.99.Final - https://netty.io/netty-transport-native-unix-common/) - * Netty/Transport/Native/Unix/Common (io.netty:netty-transport-native-unix-common:4.2.2.Final - https://netty.io/netty-transport-native-unix-common/) + * Netty/Transport/Native/Unix/Common (io.netty:netty-transport-native-unix-common:4.2.12.Final - https://netty.io/netty-transport-native-unix-common/) * OpenCensus (io.opencensus:opencensus-api:0.31.1 - https://github.com/census-instrumentation/opencensus-java) * OpenCensus (io.opencensus:opencensus-contrib-http-util:0.31.1 - https://github.com/census-instrumentation/opencensus-java) * OpenTracing API (io.opentracing:opentracing-api:0.33.0 - https://github.com/opentracing/opentracing-java/opentracing-api) @@ -175,38 +165,37 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines * JSR107 API and SPI (javax.cache:cache-api:1.1.1 - https://github.com/jsr107/jsr107spec) * javax.inject (javax.inject:javax.inject:1 - http://code.google.com/p/atinject/) * jdbm (jdbm:jdbm:1.0 - no url defined) - * Joda-Time (joda-time:joda-time:2.14.0 - https://www.joda.org/joda-time/) + * Joda-Time (joda-time:joda-time:2.14.1 - https://www.joda.org/joda-time/) * Byte Buddy (without dependencies) (net.bytebuddy:byte-buddy:1.11.13 - https://bytebuddy.net/byte-buddy) * Byte Buddy (without dependencies) (net.bytebuddy:byte-buddy:1.12.18 - https://bytebuddy.net/byte-buddy) * Byte Buddy agent (net.bytebuddy:byte-buddy-agent:1.11.13 - https://bytebuddy.net/byte-buddy-agent) * eigenbase-properties (net.hydromatic:eigenbase-properties:1.1.5 - http://github.com/julianhyde/eigenbase-properties) + * Java Native Access (net.java.dev.jna:jna:5.18.1 - https://github.com/java-native-access/jna) * json-unit-core (net.javacrumbs.json-unit:json-unit-core:2.36.0 - https://github.com/lukas-krecan/JsonUnit/json-unit-core) * "Java Concurrency in Practice" book annotations (net.jcip:jcip-annotations:1.0 - http://jcip.net/) - * ASM based accessors helper used by json-smart (net.minidev:accessors-smart:2.5.0 - https://urielch.github.io/) - * ASM based accessors helper used by json-smart (net.minidev:accessors-smart:2.5.2 - https://urielch.github.io/) - * JSON Small and Fast Parser (net.minidev:json-smart:2.5.0 - https://urielch.github.io/) - * JSON Small and Fast Parser (net.minidev:json-smart:2.5.2 - https://urielch.github.io/) + * ASM based accessors helper used by json-smart (net.minidev:accessors-smart:2.6.0 - https://urielch.github.io/) + * JSON Small and Fast Parser (net.minidev:json-smart:2.6.0 - https://urielch.github.io/) * Abdera Core (org.apache.abdera:abdera-core:1.1.3 - http://abdera.apache.org/abdera-core) * I18N Libraries (org.apache.abdera:abdera-i18n:1.1.3 - http://abdera.apache.org) - * Apache Ant Core (org.apache.ant:ant:1.10.15 - https://ant.apache.org/) - * Apache Ant Launcher (org.apache.ant:ant-launcher:1.10.15 - https://ant.apache.org/) - * Apache Commons BCEL (org.apache.bcel:bcel:6.10.0 - https://commons.apache.org/proper/commons-bcel) + * Apache Ant Core (org.apache.ant:ant:1.10.17 - https://ant.apache.org/) + * Apache Ant Launcher (org.apache.ant:ant-launcher:1.10.17 - https://ant.apache.org/) + * Apache Commons BCEL (org.apache.bcel:bcel:6.12.0 - https://commons.apache.org/proper/commons-bcel) * Calcite Core (org.apache.calcite:calcite-core:1.35.0 - https://calcite.apache.org) * Calcite Linq4j (org.apache.calcite:calcite-linq4j:1.35.0 - https://calcite.apache.org) * Apache Calcite Avatica (org.apache.calcite.avatica:avatica-core:1.23.0 - https://calcite.apache.org/avatica) * Apache Calcite Avatica Metrics (org.apache.calcite.avatica:avatica-metrics:1.23.0 - https://calcite.apache.org/avatica) * Apache Commons Collections (org.apache.commons:commons-collections4:4.5.0 - https://commons.apache.org/proper/commons-collections/) - * Apache Commons Compress (org.apache.commons:commons-compress:1.27.1 - https://commons.apache.org/proper/commons-compress/) - * Apache Commons Configuration (org.apache.commons:commons-configuration2:2.12.0 - https://commons.apache.org/proper/commons-configuration/) - * Apache Commons CSV (org.apache.commons:commons-csv:1.14.0 - https://commons.apache.org/proper/commons-csv/) - * Apache Commons DBCP (org.apache.commons:commons-dbcp2:2.13.0 - https://commons.apache.org/proper/commons-dbcp/) + * Apache Commons Compress (org.apache.commons:commons-compress:1.28.0 - https://commons.apache.org/proper/commons-compress/) + * Apache Commons Configuration (org.apache.commons:commons-configuration2:2.15.0 - https://commons.apache.org/proper/commons-configuration/) + * Apache Commons CSV (org.apache.commons:commons-csv:1.14.1 - https://commons.apache.org/proper/commons-csv/) + * Apache Commons DBCP (org.apache.commons:commons-dbcp2:2.14.0 - https://commons.apache.org/proper/commons-dbcp/) * Apache Commons Digester (org.apache.commons:commons-digester3:3.2 - http://commons.apache.org/digester/) * Apache Commons Exec (org.apache.commons:commons-exec:1.3 - http://commons.apache.org/proper/commons-exec/) - * Apache Commons Exec (org.apache.commons:commons-exec:1.4.0 - https://commons.apache.org/proper/commons-exec/) - * Apache Commons Lang (org.apache.commons:commons-lang3:3.17.0 - https://commons.apache.org/proper/commons-lang/) + * Apache Commons Exec (org.apache.commons:commons-exec:1.6.0 - https://commons.apache.org/proper/commons-exec/) + * Apache Commons Lang (org.apache.commons:commons-lang3:3.20.0 - https://commons.apache.org/proper/commons-lang/) * Apache Commons Math (org.apache.commons:commons-math3:3.6.1 - http://commons.apache.org/proper/commons-math/) - * Apache Commons Pool (org.apache.commons:commons-pool2:2.12.1 - https://commons.apache.org/proper/commons-pool/) - * Apache Commons Text (org.apache.commons:commons-text:1.13.1 - https://commons.apache.org/proper/commons-text) + * Apache Commons Pool (org.apache.commons:commons-pool2:2.13.1 - https://commons.apache.org/proper/commons-pool/) + * Apache Commons Text (org.apache.commons:commons-text:1.15.0 - https://commons.apache.org/proper/commons-text) * Curator Client (org.apache.curator:curator-client:2.13.0 - http://curator.apache.org/curator-client) * Curator Framework (org.apache.curator:curator-framework:2.13.0 - http://curator.apache.org/curator-framework) * Curator Recipes (org.apache.curator:curator-recipes:2.13.0 - http://curator.apache.org/curator-recipes) @@ -222,8 +211,8 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines * Apache HttpClient (org.apache.httpcomponents.client5:httpclient5:5.1.3 - https://hc.apache.org/httpcomponents-client-5.0.x/5.1.3/httpclient5/) * Apache HttpComponents Core HTTP/1.1 (org.apache.httpcomponents.core5:httpcore5:5.1.3 - https://hc.apache.org/httpcomponents-core-5.1.x/5.1.3/httpcore5/) * Apache HttpComponents Core HTTP/2 (org.apache.httpcomponents.core5:httpcore5-h2:5.1.3 - https://hc.apache.org/httpcomponents-core-5.1.x/5.1.3/httpcore5-h2/) - * Apache James :: Mime4j :: Core (org.apache.james:apache-mime4j-core:0.8.12 - http://james.apache.org/mime4j/apache-mime4j-core) - * Apache James :: Mime4j :: DOM (org.apache.james:apache-mime4j-dom:0.8.12 - http://james.apache.org/mime4j/apache-mime4j-dom) + * Apache James :: Mime4j :: Core (org.apache.james:apache-mime4j-core:0.8.14 - http://james.apache.org/mime4j/apache-mime4j-core) + * Apache James :: Mime4j :: DOM (org.apache.james:apache-mime4j-dom:0.8.13 - http://james.apache.org/mime4j/apache-mime4j-dom) * Apache Jena - Libraries POM (org.apache.jena:apache-jena-libs:2.13.0 - http://jena.apache.org/apache-jena-libs/) * Apache Jena - ARQ (SPARQL 1.1 Query Engine) (org.apache.jena:jena-arq:2.13.0 - http://jena.apache.org/jena-arq/) * Apache Jena - Core (org.apache.jena:jena-core:2.13.0 - http://jena.apache.org/jena-core/) @@ -233,9 +222,9 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines * Kerby-kerb Util (org.apache.kerby:kerb-util:1.0.1 - http://directory.apache.org/kerby/kerby-kerb/kerb-util) * Kerby ASN1 Project (org.apache.kerby:kerby-asn1:1.0.1 - http://directory.apache.org/kerby/kerby-common/kerby-asn1) * Kerby PKIX Project (org.apache.kerby:kerby-pkix:1.0.1 - http://directory.apache.org/kerby/kerby-pkix) - * Apache Log4j 1.x Compatibility API (org.apache.logging.log4j:log4j-1.2-api:2.25.1 - https://logging.apache.org/log4j/2.x/) - * Apache Log4j API (org.apache.logging.log4j:log4j-api:2.25.1 - https://logging.apache.org/log4j/2.x/) - * Apache Log4j Core (org.apache.logging.log4j:log4j-core:2.25.1 - https://logging.apache.org/log4j/2.x/) + * Apache Log4j 1.x Compatibility API (org.apache.logging.log4j:log4j-1.2-api:2.25.4 - https://logging.apache.org/log4j/2.x/) + * Apache Log4j API (org.apache.logging.log4j:log4j-api:2.25.4 - https://logging.apache.org/log4j/2.x/) + * Apache Log4j Core (org.apache.logging.log4j:log4j-core:2.25.4 - https://logging.apache.org/log4j/2.x/) * Apache Log4j JUL Adapter (org.apache.logging.log4j:log4j-jul:2.17.2 - https://logging.apache.org/log4j/2.x/log4j-jul/) * Apache Log4j Layout for JSON template (org.apache.logging.log4j:log4j-layout-template-json:2.17.2 - https://logging.apache.org/log4j/2.x/log4j-layout-template-json/) * Apache Log4j SLF4J Binding (org.apache.logging.log4j:log4j-slf4j-impl:2.17.2 - https://logging.apache.org/log4j/2.x/log4j-slf4j-impl/) @@ -263,45 +252,46 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines * Lucene Spatial Extras (org.apache.lucene:lucene-spatial-extras:8.11.4 - https://lucene.apache.org/lucene-parent/lucene-spatial-extras) * Lucene Spatial 3D (org.apache.lucene:lucene-spatial3d:8.11.4 - https://lucene.apache.org/lucene-parent/lucene-spatial3d) * Lucene Suggest (org.apache.lucene:lucene-suggest:8.11.4 - https://lucene.apache.org/lucene-parent/lucene-suggest) - * Apache FontBox (org.apache.pdfbox:fontbox:2.0.34 - http://pdfbox.apache.org/) + * Apache FontBox (org.apache.pdfbox:fontbox:3.0.7 - http://pdfbox.apache.org/) * PDFBox JBIG2 ImageIO plugin (org.apache.pdfbox:jbig2-imageio:3.0.4 - https://www.apache.org/jbig2-imageio/) * Apache JempBox (org.apache.pdfbox:jempbox:1.8.17 - http://www.apache.org/pdfbox-parent/jempbox/) - * Apache PDFBox (org.apache.pdfbox:pdfbox:2.0.34 - https://www.apache.org/pdfbox-parent/pdfbox/) - * Apache PDFBox tools (org.apache.pdfbox:pdfbox-tools:2.0.34 - https://www.apache.org/pdfbox-parent/pdfbox-tools/) - * Apache XmpBox (org.apache.pdfbox:xmpbox:2.0.34 - https://www.apache.org/pdfbox-parent/xmpbox/) - * Apache POI - Common (org.apache.poi:poi:5.4.1 - https://poi.apache.org/) - * Apache POI - API based on OPC and OOXML schemas (org.apache.poi:poi-ooxml:5.4.1 - https://poi.apache.org/) - * Apache POI (org.apache.poi:poi-ooxml-lite:5.4.1 - https://poi.apache.org/) - * Apache POI (org.apache.poi:poi-scratchpad:5.4.1 - https://poi.apache.org/) + * Apache PDFBox (org.apache.pdfbox:pdfbox:3.0.7 - https://www.apache.org/pdfbox-parent/pdfbox/) + * Apache PDFBox io (org.apache.pdfbox:pdfbox-io:3.0.7 - https://www.apache.org/pdfbox-parent/pdfbox-io/) + * Apache PDFBox tools (org.apache.pdfbox:pdfbox-tools:3.0.7 - https://www.apache.org/pdfbox-parent/pdfbox-tools/) + * Apache XmpBox (org.apache.pdfbox:xmpbox:3.0.7 - https://www.apache.org/pdfbox-parent/xmpbox/) + * Apache POI - Common (org.apache.poi:poi:5.5.1 - https://poi.apache.org/) + * Apache POI - API based on OPC and OOXML schemas (org.apache.poi:poi-ooxml:5.5.1 - https://poi.apache.org/) + * Apache POI - OOXML schemas (full) (org.apache.poi:poi-ooxml-full:5.5.1 - https://poi.apache.org/) + * Apache POI (org.apache.poi:poi-scratchpad:5.5.1 - https://poi.apache.org/) * Apache Solr Core (org.apache.solr:solr-core:8.11.4 - https://lucene.apache.org/solr-parent/solr-core) * Apache Solr Solrj (org.apache.solr:solr-solrj:8.11.4 - https://lucene.apache.org/solr-parent/solr-solrj) * Apache Standard Taglib Implementation (org.apache.taglibs:taglibs-standard-impl:1.2.5 - http://tomcat.apache.org/taglibs/standard-1.2.5/taglibs-standard-impl) * Apache Standard Taglib Specification API (org.apache.taglibs:taglibs-standard-spec:1.2.5 - http://tomcat.apache.org/taglibs/standard-1.2.5/taglibs-standard-spec) * Apache Thrift (org.apache.thrift:libthrift:0.9.2 - http://thrift.apache.org) - * Apache Tika core (org.apache.tika:tika-core:2.9.4 - https://tika.apache.org/) - * Apache Tika Apple parser module (org.apache.tika:tika-parser-apple-module:2.9.4 - https://tika.apache.org/tika-parser-apple-module/) - * Apache Tika audiovideo parser module (org.apache.tika:tika-parser-audiovideo-module:2.9.4 - https://tika.apache.org/tika-parser-audiovideo-module/) - * Apache Tika cad parser module (org.apache.tika:tika-parser-cad-module:2.9.4 - https://tika.apache.org/tika-parser-cad-module/) - * Apache Tika code parser module (org.apache.tika:tika-parser-code-module:2.9.4 - https://tika.apache.org/tika-parser-code-module/) - * Apache Tika crypto parser module (org.apache.tika:tika-parser-crypto-module:2.9.4 - https://tika.apache.org/tika-parser-crypto-module/) - * Apache Tika digest commons (org.apache.tika:tika-parser-digest-commons:2.9.4 - https://tika.apache.org/tika-parser-digest-commons/) - * Apache Tika font parser module (org.apache.tika:tika-parser-font-module:2.9.4 - https://tika.apache.org/tika-parser-font-module/) - * Apache Tika html parser module (org.apache.tika:tika-parser-html-module:2.9.4 - https://tika.apache.org/tika-parser-html-module/) - * Apache Tika image parser module (org.apache.tika:tika-parser-image-module:2.9.4 - https://tika.apache.org/tika-parser-image-module/) - * Apache Tika mail commons (org.apache.tika:tika-parser-mail-commons:2.9.4 - https://tika.apache.org/tika-parser-mail-commons/) - * Apache Tika mail parser module (org.apache.tika:tika-parser-mail-module:2.9.4 - https://tika.apache.org/tika-parser-mail-module/) - * Apache Tika Microsoft parser module (org.apache.tika:tika-parser-microsoft-module:2.9.4 - https://tika.apache.org/tika-parser-microsoft-module/) - * Apache Tika miscellaneous office format parser module (org.apache.tika:tika-parser-miscoffice-module:2.9.4 - https://tika.apache.org/tika-parser-miscoffice-module/) - * Apache Tika news parser module (org.apache.tika:tika-parser-news-module:2.9.4 - https://tika.apache.org/tika-parser-news-module/) - * Apache Tika OCR parser module (org.apache.tika:tika-parser-ocr-module:2.9.4 - https://tika.apache.org/tika-parser-ocr-module/) - * Apache Tika PDF parser module (org.apache.tika:tika-parser-pdf-module:2.9.4 - https://tika.apache.org/tika-parser-pdf-module/) - * Apache Tika package parser module (org.apache.tika:tika-parser-pkg-module:2.9.4 - https://tika.apache.org/tika-parser-pkg-module/) - * Apache Tika text parser module (org.apache.tika:tika-parser-text-module:2.9.4 - https://tika.apache.org/tika-parser-text-module/) - * Apache Tika WARC parser module (org.apache.tika:tika-parser-webarchive-module:2.9.4 - https://tika.apache.org/tika-parser-webarchive-module/) - * Apache Tika XML parser module (org.apache.tika:tika-parser-xml-module:2.9.4 - https://tika.apache.org/tika-parser-xml-module/) - * Apache Tika XMP commons (org.apache.tika:tika-parser-xmp-commons:2.9.4 - https://tika.apache.org/tika-parser-xmp-commons/) - * Apache Tika ZIP commons (org.apache.tika:tika-parser-zip-commons:2.9.4 - https://tika.apache.org/tika-parser-zip-commons/) - * Apache Tika standard parser package (org.apache.tika:tika-parsers-standard-package:2.9.4 - https://tika.apache.org/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/) + * Apache Tika core (org.apache.tika:tika-core:3.3.0 - https://tika.apache.org/) + * Apache Tika Apple parser module (org.apache.tika:tika-parser-apple-module:3.3.0 - https://tika.apache.org/tika-parser-apple-module/) + * Apache Tika audiovideo parser module (org.apache.tika:tika-parser-audiovideo-module:3.3.0 - https://tika.apache.org/tika-parser-audiovideo-module/) + * Apache Tika cad parser module (org.apache.tika:tika-parser-cad-module:3.3.0 - https://tika.apache.org/tika-parser-cad-module/) + * Apache Tika code parser module (org.apache.tika:tika-parser-code-module:3.3.0 - https://tika.apache.org/tika-parser-code-module/) + * Apache Tika crypto parser module (org.apache.tika:tika-parser-crypto-module:3.3.0 - https://tika.apache.org/tika-parser-crypto-module/) + * Apache Tika digest commons (org.apache.tika:tika-parser-digest-commons:3.3.0 - https://tika.apache.org/tika-parser-digest-commons/) + * Apache Tika font parser module (org.apache.tika:tika-parser-font-module:3.3.0 - https://tika.apache.org/tika-parser-font-module/) + * Apache Tika html parser module (org.apache.tika:tika-parser-html-module:3.3.0 - https://tika.apache.org/tika-parser-html-module/) + * Apache Tika image parser module (org.apache.tika:tika-parser-image-module:3.3.0 - https://tika.apache.org/tika-parser-image-module/) + * Apache Tika mail commons (org.apache.tika:tika-parser-mail-commons:3.3.0 - https://tika.apache.org/tika-parser-mail-commons/) + * Apache Tika mail parser module (org.apache.tika:tika-parser-mail-module:3.3.0 - https://tika.apache.org/tika-parser-mail-module/) + * Apache Tika Microsoft parser module (org.apache.tika:tika-parser-microsoft-module:3.3.0 - https://tika.apache.org/tika-parser-microsoft-module/) + * Apache Tika miscellaneous office format parser module (org.apache.tika:tika-parser-miscoffice-module:3.3.0 - https://tika.apache.org/tika-parser-miscoffice-module/) + * Apache Tika news parser module (org.apache.tika:tika-parser-news-module:3.3.0 - https://tika.apache.org/tika-parser-news-module/) + * Apache Tika OCR parser module (org.apache.tika:tika-parser-ocr-module:3.3.0 - https://tika.apache.org/tika-parser-ocr-module/) + * Apache Tika PDF parser module (org.apache.tika:tika-parser-pdf-module:3.3.0 - https://tika.apache.org/tika-parser-pdf-module/) + * Apache Tika package parser module (org.apache.tika:tika-parser-pkg-module:3.3.0 - https://tika.apache.org/tika-parser-pkg-module/) + * Apache Tika text parser module (org.apache.tika:tika-parser-text-module:3.3.0 - https://tika.apache.org/tika-parser-text-module/) + * Apache Tika WARC parser module (org.apache.tika:tika-parser-webarchive-module:3.3.0 - https://tika.apache.org/tika-parser-webarchive-module/) + * Apache Tika XML parser module (org.apache.tika:tika-parser-xml-module:3.3.0 - https://tika.apache.org/tika-parser-xml-module/) + * Apache Tika XMP commons (org.apache.tika:tika-parser-xmp-commons:3.3.0 - https://tika.apache.org/tika-parser-xmp-commons/) + * Apache Tika ZIP commons (org.apache.tika:tika-parser-zip-commons:3.3.0 - https://tika.apache.org/tika-parser-zip-commons/) + * Apache Tika standard parser package (org.apache.tika:tika-parsers-standard-package:3.3.0 - https://tika.apache.org/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/) * tomcat-embed-core (org.apache.tomcat.embed:tomcat-embed-core:9.0.83 - https://tomcat.apache.org/) * tomcat-embed-el (org.apache.tomcat.embed:tomcat-embed-el:9.0.83 - https://tomcat.apache.org/) * tomcat-embed-websocket (org.apache.tomcat.embed:tomcat-embed-websocket:9.0.83 - https://tomcat.apache.org/) @@ -317,63 +307,61 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines * AssertJ fluent assertions (org.assertj:assertj-core:3.22.0 - https://assertj.github.io/doc/assertj-core/) * Evo Inflector (org.atteo:evo-inflector:1.3 - http://atteo.org/static/evo-inflector) * jose4j (org.bitbucket.b_c:jose4j:0.6.5 - https://bitbucket.org/b_c/jose4j/) - * TagSoup (org.ccil.cowan.tagsoup:tagsoup:1.2.1 - http://home.ccil.org/~cowan/XML/tagsoup/) * jems (org.dmfs:jems:1.18 - https://github.com/dmfs/jems) * rfc3986-uri (org.dmfs:rfc3986-uri:0.8.1 - https://github.com/dmfs/uri-toolkit) * Jetty :: Apache JSP Implementation (org.eclipse.jetty:apache-jsp:9.4.15.v20190215 - http://www.eclipse.org/jetty) * Apache :: JSTL module (org.eclipse.jetty:apache-jstl:9.4.15.v20190215 - http://tomcat.apache.org/taglibs/standard/) * Jetty :: ALPN :: Client (org.eclipse.jetty:jetty-alpn-client:9.4.53.v20231009 - https://eclipse.org/jetty/jetty-alpn-parent/jetty-alpn-client) * Jetty :: ALPN :: JDK9 Client Implementation (org.eclipse.jetty:jetty-alpn-java-client:9.4.53.v20231009 - https://eclipse.org/jetty/jetty-alpn-parent/jetty-alpn-java-client) - * Jetty :: ALPN :: JDK9 Server Implementation (org.eclipse.jetty:jetty-alpn-java-server:9.4.57.v20241219 - https://jetty.org/jetty-alpn-parent/jetty-alpn-java-server/) + * Jetty :: ALPN :: JDK9 Server Implementation (org.eclipse.jetty:jetty-alpn-java-server:9.4.58.v20250814 - https://jetty.org/jetty-alpn-parent/jetty-alpn-java-server/) * Jetty :: ALPN :: Server (org.eclipse.jetty:jetty-alpn-server:9.4.53.v20231009 - https://eclipse.org/jetty/jetty-alpn-parent/jetty-alpn-server) - * Jetty :: ALPN :: Server (org.eclipse.jetty:jetty-alpn-server:9.4.57.v20241219 - https://jetty.org/jetty-alpn-parent/jetty-alpn-server/) + * Jetty :: ALPN :: Server (org.eclipse.jetty:jetty-alpn-server:9.4.58.v20250814 - https://jetty.org/jetty-alpn-parent/jetty-alpn-server/) * Jetty :: Servlet Annotations (org.eclipse.jetty:jetty-annotations:9.4.15.v20190215 - http://www.eclipse.org/jetty) * Jetty :: Asynchronous HTTP Client (org.eclipse.jetty:jetty-client:9.4.53.v20231009 - https://eclipse.org/jetty/jetty-client) * Jetty :: Continuation (org.eclipse.jetty:jetty-continuation:9.4.53.v20231009 - https://eclipse.org/jetty/jetty-continuation) - * Jetty :: Continuation (org.eclipse.jetty:jetty-continuation:9.4.57.v20241219 - https://jetty.org/jetty-continuation/) - * Jetty :: Deployers (org.eclipse.jetty:jetty-deploy:9.4.57.v20241219 - https://jetty.org/jetty-deploy/) - * Jetty :: Http Utility (org.eclipse.jetty:jetty-http:9.4.57.v20241219 - https://jetty.org/jetty-http/) - * Jetty :: IO Utility (org.eclipse.jetty:jetty-io:9.4.57.v20241219 - https://jetty.org/jetty-io/) + * Jetty :: Continuation (org.eclipse.jetty:jetty-continuation:9.4.58.v20250814 - https://jetty.org/jetty-continuation/) + * Jetty :: Deployers (org.eclipse.jetty:jetty-deploy:9.4.58.v20250814 - https://jetty.org/jetty-deploy/) + * Jetty :: Http Utility (org.eclipse.jetty:jetty-http:9.4.58.v20250814 - https://jetty.org/jetty-http/) + * Jetty :: IO Utility (org.eclipse.jetty:jetty-io:9.4.58.v20250814 - https://jetty.org/jetty-io/) * Jetty :: JMX Management (org.eclipse.jetty:jetty-jmx:9.4.53.v20231009 - https://eclipse.org/jetty/jetty-jmx) * Jetty :: JNDI Naming (org.eclipse.jetty:jetty-jndi:9.4.15.v20190215 - http://www.eclipse.org/jetty) * Jetty :: Plus (org.eclipse.jetty:jetty-plus:9.4.15.v20190215 - http://www.eclipse.org/jetty) * Jetty :: Rewrite Handler (org.eclipse.jetty:jetty-rewrite:9.4.53.v20231009 - https://eclipse.org/jetty/jetty-rewrite) * Jetty :: Security (org.eclipse.jetty:jetty-security:9.4.53.v20231009 - https://eclipse.org/jetty/jetty-security) - * Jetty :: Security (org.eclipse.jetty:jetty-security:9.4.57.v20241219 - https://jetty.org/jetty-security/) - * Jetty :: Server Core (org.eclipse.jetty:jetty-server:9.4.57.v20241219 - https://jetty.org/jetty-server/) - * Jetty :: Servlet Handling (org.eclipse.jetty:jetty-servlet:9.4.57.v20241219 - https://jetty.org/jetty-servlet/) - * Jetty :: Utility Servlets and Filters (org.eclipse.jetty:jetty-servlets:9.4.57.v20241219 - https://jetty.org/jetty-servlets/) - * Jetty :: Utilities (org.eclipse.jetty:jetty-util:9.4.57.v20241219 - https://jetty.org/jetty-util/) - * Jetty :: Utilities :: Ajax(JSON) (org.eclipse.jetty:jetty-util-ajax:9.4.57.v20241219 - https://jetty.org/jetty-util-ajax/) - * Jetty :: Webapp Application Support (org.eclipse.jetty:jetty-webapp:9.4.57.v20241219 - https://jetty.org/jetty-webapp/) - * Jetty :: XML utilities (org.eclipse.jetty:jetty-xml:9.4.57.v20241219 - https://jetty.org/jetty-xml/) + * Jetty :: Security (org.eclipse.jetty:jetty-security:9.4.58.v20250814 - https://jetty.org/jetty-security/) + * Jetty :: Server Core (org.eclipse.jetty:jetty-server:9.4.58.v20250814 - https://jetty.org/jetty-server/) + * Jetty :: Servlet Handling (org.eclipse.jetty:jetty-servlet:9.4.58.v20250814 - https://jetty.org/jetty-servlet/) + * Jetty :: Utility Servlets and Filters (org.eclipse.jetty:jetty-servlets:9.4.58.v20250814 - https://jetty.org/jetty-servlets/) + * Jetty :: Utilities (org.eclipse.jetty:jetty-util:9.4.58.v20250814 - https://jetty.org/jetty-util/) + * Jetty :: Utilities :: Ajax(JSON) (org.eclipse.jetty:jetty-util-ajax:9.4.58.v20250814 - https://jetty.org/jetty-util-ajax/) + * Jetty :: Webapp Application Support (org.eclipse.jetty:jetty-webapp:9.4.58.v20250814 - https://jetty.org/jetty-webapp/) + * Jetty :: XML utilities (org.eclipse.jetty:jetty-xml:9.4.58.v20250814 - https://jetty.org/jetty-xml/) * Jetty :: HTTP2 :: Client (org.eclipse.jetty.http2:http2-client:9.4.53.v20231009 - https://eclipse.org/jetty/http2-parent/http2-client) - * Jetty :: HTTP2 :: Common (org.eclipse.jetty.http2:http2-common:9.4.57.v20241219 - https://jetty.org/http2-parent/http2-common/) + * Jetty :: HTTP2 :: Common (org.eclipse.jetty.http2:http2-common:9.4.58.v20250814 - https://jetty.org/http2-parent/http2-common/) * Jetty :: HTTP2 :: HPACK (org.eclipse.jetty.http2:http2-hpack:9.4.53.v20231009 - https://eclipse.org/jetty/http2-parent/http2-hpack) * Jetty :: HTTP2 :: HTTP Client Transport (org.eclipse.jetty.http2:http2-http-client-transport:9.4.53.v20231009 - https://eclipse.org/jetty/http2-parent/http2-http-client-transport) - * Jetty :: HTTP2 :: Server (org.eclipse.jetty.http2:http2-server:9.4.57.v20241219 - https://jetty.org/http2-parent/http2-server/) + * Jetty :: HTTP2 :: Server (org.eclipse.jetty.http2:http2-server:9.4.58.v20250814 - https://jetty.org/http2-parent/http2-server/) * Jetty :: Schemas (org.eclipse.jetty.toolchain:jetty-schemas:3.1.2 - https://eclipse.org/jetty/jetty-schemas) - * Ehcache (org.ehcache:ehcache:3.10.8 - http://ehcache.org) + * Ehcache (org.ehcache:ehcache:3.11.1 - http://ehcache.org) * flyway-core (org.flywaydb:flyway-core:8.5.13 - https://flywaydb.org/flyway-core) * Ogg and Vorbis for Java, Core (org.gagravarr:vorbis-java-core:0.8 - https://github.com/Gagravarr/VorbisJava) * Apache Tika plugin for Ogg, Vorbis and FLAC (org.gagravarr:vorbis-java-tika:0.8 - https://github.com/Gagravarr/VorbisJava) - * jersey-core-client (org.glassfish.jersey.core:jersey-client:2.47 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-client) - * jersey-core-common (org.glassfish.jersey.core:jersey-common:2.47 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-common) - * jersey-inject-hk2 (org.glassfish.jersey.inject:jersey-hk2:2.47 - https://projects.eclipse.org/projects/ee4j.jersey/project/jersey-hk2) + * jersey-core-client (org.glassfish.jersey.core:jersey-client:2.48 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-client) + * jersey-core-common (org.glassfish.jersey.core:jersey-common:2.48 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-common) + * jersey-inject-hk2 (org.glassfish.jersey.inject:jersey-hk2:2.48 - https://projects.eclipse.org/projects/ee4j.jersey/project/jersey-hk2) * Hibernate Validator Engine (org.hibernate.validator:hibernate-validator:6.2.5.Final - http://hibernate.org/validator/hibernate-validator) * Hibernate Validator Portable Extension (org.hibernate.validator:hibernate-validator-cdi:6.2.5.Final - http://hibernate.org/validator/hibernate-validator-cdi) * org.immutables.value-annotations (org.immutables:value-annotations:2.9.2 - http://immutables.org/value-annotations) - * leveldb (org.iq80.leveldb:leveldb:0.12 - http://github.com/dain/leveldb/leveldb) - * leveldb-api (org.iq80.leveldb:leveldb-api:0.12 - http://github.com/dain/leveldb/leveldb-api) * Javassist (org.javassist:javassist:3.30.2-GA - https://www.javassist.org/) * Java Annotation Indexer (org.jboss:jandex:2.4.2.Final - http://www.jboss.org/jandex) - * JBoss Logging 3 (org.jboss.logging:jboss-logging:3.6.1.Final - http://www.jboss.org) + * JBoss Logging 3 (org.jboss.logging:jboss-logging:3.4.3.Final - http://www.jboss.org) * JDOM (org.jdom:jdom2:2.0.6.1 - http://www.jdom.org) - * IntelliJ IDEA Annotations (org.jetbrains:annotations:13.0 - http://www.jetbrains.org) + * JetBrains Java Annotations (org.jetbrains:annotations:17.0.0 - https://github.com/JetBrains/java-annotations) * Kotlin Stdlib (org.jetbrains.kotlin:kotlin-stdlib:1.8.21 - https://kotlinlang.org/) * Kotlin Stdlib Common (org.jetbrains.kotlin:kotlin-stdlib-common:1.8.21 - https://kotlinlang.org/) * Kotlin Stdlib Jdk7 (org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.21 - https://kotlinlang.org/) * Kotlin Stdlib Jdk8 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.21 - https://kotlinlang.org/) + * JSpecify annotations (org.jspecify:jspecify:1.0.0 - http://jspecify.org/) * jtwig-core (org.jtwig:jtwig-core:5.87.0.RELEASE - http://jtwig.org) * jtwig-reflection (org.jtwig:jtwig-reflection:5.87.0.RELEASE - http://jtwig.org) * jtwig-spring (org.jtwig:jtwig-spring:5.87.0.RELEASE - http://jtwig.org) @@ -391,18 +379,11 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines * Jetty Servlet Tester (org.mortbay.jetty:jetty-servlet-tester:6.1.26 - http://www.eclipse.org/jetty/jetty-parent/project/jetty-servlet-tester) * Jetty Utilities (org.mortbay.jetty:jetty-util:6.1.26 - http://www.eclipse.org/jetty/jetty-parent/project/jetty-util) * Servlet Specification API (org.mortbay.jetty:servlet-api:2.5-20081211 - http://jetty.mortbay.org/servlet-api) - * jwarc (org.netpreserve:jwarc:0.31.1 - https://github.com/iipc/jwarc) + * jwarc (org.netpreserve:jwarc:0.35.0 - https://github.com/iipc/jwarc) * Objenesis (org.objenesis:objenesis:3.2 - http://objenesis.org/objenesis) - * org.opentest4j:opentest4j (org.opentest4j:opentest4j:1.3.0 - https://github.com/ota4j-team/opentest4j) * parboiled-core (org.parboiled:parboiled-core:1.1.7 - http://parboiled.org) * parboiled-java (org.parboiled:parboiled-java:1.1.7 - http://parboiled.org) * RRD4J (org.rrd4j:rrd4j:3.5 - https://github.com/rrd4j/rrd4j/) - * Scala Library (org.scala-lang:scala-library:2.13.16 - https://www.scala-lang.org/) - * Scala Compiler (org.scala-lang:scala-reflect:2.13.0 - https://www.scala-lang.org/) - * scala-collection-compat (org.scala-lang.modules:scala-collection-compat_2.13:2.1.6 - http://www.scala-lang.org/) - * scala-java8-compat (org.scala-lang.modules:scala-java8-compat_2.13:0.9.0 - http://www.scala-lang.org/) - * scala-parser-combinators (org.scala-lang.modules:scala-parser-combinators_2.13:1.1.2 - http://www.scala-lang.org/) - * scala-xml (org.scala-lang.modules:scala-xml_2.13:1.3.0 - http://www.scala-lang.org/) * JSONassert (org.skyscreamer:jsonassert:1.5.1 - https://github.com/skyscreamer/JSONassert) * JCL 1.2 implemented over SLF4J (org.slf4j:jcl-over-slf4j:1.7.36 - http://www.slf4j.org) * Spring AOP (org.springframework:spring-aop:5.3.39 - https://github.com/spring-projects/spring-framework) @@ -453,13 +434,47 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines * SWORD v2 :: Common Server Library (org.swordapp:sword2-server:1.0 - http://www.swordapp.org/) * snappy-java (org.xerial.snappy:snappy-java:1.1.10.1 - https://github.com/xerial/snappy-java) * xml-matchers (org.xmlmatchers:xml-matchers:0.10 - http://code.google.com/p/xml-matchers/) - * org.xmlunit:xmlunit-core (org.xmlunit:xmlunit-core:2.10.2 - https://www.xmlunit.org/) + * org.xmlunit:xmlunit-core (org.xmlunit:xmlunit-core:2.11.0 - https://www.xmlunit.org/) * org.xmlunit:xmlunit-core (org.xmlunit:xmlunit-core:2.9.1 - https://www.xmlunit.org/) * org.xmlunit:xmlunit-placeholders (org.xmlunit:xmlunit-placeholders:2.9.1 - https://www.xmlunit.org/xmlunit-placeholders/) * SnakeYAML (org.yaml:snakeyaml:1.30 - https://bitbucket.org/snakeyaml/snakeyaml) + * AWS Java SDK :: Annotations (software.amazon.awssdk:annotations:2.42.40 - https://aws.amazon.com/sdkforjava/core/annotations) + * AWS Java SDK :: Arns (software.amazon.awssdk:arns:2.42.40 - https://aws.amazon.com/sdkforjava) + * AWS Java SDK :: Auth (software.amazon.awssdk:auth:2.42.40 - https://aws.amazon.com/sdkforjava) + * AWS Java SDK :: AWS Core (software.amazon.awssdk:aws-core:2.42.40 - https://aws.amazon.com/sdkforjava) + * AWS Java SDK :: Core :: Protocols :: AWS Query Protocol (software.amazon.awssdk:aws-query-protocol:2.42.40 - https://aws.amazon.com/sdkforjava) + * AWS Java SDK :: Core :: Protocols :: AWS Xml Protocol (software.amazon.awssdk:aws-xml-protocol:2.42.40 - https://aws.amazon.com/sdkforjava) + * AWS Java SDK :: Checksums (software.amazon.awssdk:checksums:2.42.40 - https://aws.amazon.com/sdkforjava) + * AWS Java SDK :: Checksums SPI (software.amazon.awssdk:checksums-spi:2.42.40 - https://aws.amazon.com/sdkforjava) + * AWS Java SDK :: AWS CRT Core (software.amazon.awssdk:crt-core:2.42.40 - https://aws.amazon.com/sdkforjava) + * AWS Java SDK :: Endpoints SPI (software.amazon.awssdk:endpoints-spi:2.42.40 - https://aws.amazon.com/sdkforjava/core/endpoints-spi) + * AWS Java SDK :: HTTP Auth (software.amazon.awssdk:http-auth:2.42.40 - https://aws.amazon.com/sdkforjava) + * AWS Java SDK :: HTTP Auth AWS (software.amazon.awssdk:http-auth-aws:2.42.40 - https://aws.amazon.com/sdkforjava) + * AWS Java SDK :: HTTP Auth Event Stream (software.amazon.awssdk:http-auth-aws-eventstream:2.42.40 - https://aws.amazon.com/sdkforjava) + * AWS Java SDK :: HTTP Auth SPI (software.amazon.awssdk:http-auth-spi:2.42.40 - https://aws.amazon.com/sdkforjava) + * AWS Java SDK :: HTTP Client Interface (software.amazon.awssdk:http-client-spi:2.42.40 - https://aws.amazon.com/sdkforjava/http-client-spi) + * AWS Java SDK :: Identity SPI (software.amazon.awssdk:identity-spi:2.42.40 - https://aws.amazon.com/sdkforjava) + * AWS Java SDK :: Core :: Protocols :: Json Utils (software.amazon.awssdk:json-utils:2.42.40 - https://aws.amazon.com/sdkforjava) + * AWS Java SDK :: Metrics SPI (software.amazon.awssdk:metrics-spi:2.42.40 - https://aws.amazon.com/sdkforjava/core/metrics-spi) + * AWS Java SDK :: Profiles (software.amazon.awssdk:profiles:2.42.40 - https://aws.amazon.com/sdkforjava) + * AWS Java SDK :: Core :: Protocols :: Protocol Core (software.amazon.awssdk:protocol-core:2.42.40 - https://aws.amazon.com/sdkforjava) + * AWS Java SDK :: Regions (software.amazon.awssdk:regions:2.42.40 - https://aws.amazon.com/sdkforjava/core/regions) + * AWS Java SDK :: Retries (software.amazon.awssdk:retries:2.42.40 - https://aws.amazon.com/sdkforjava/core/retries) + * AWS Java SDK :: Retries API (software.amazon.awssdk:retries-spi:2.42.40 - https://aws.amazon.com/sdkforjava/core/retries-spi) + * AWS Java SDK :: Services :: Amazon S3 (software.amazon.awssdk:s3:2.42.40 - https://aws.amazon.com/sdkforjava) + * AWS Java SDK :: SDK Core (software.amazon.awssdk:sdk-core:2.42.40 - https://aws.amazon.com/sdkforjava) + * AWS Java SDK :: Third Party :: Jackson-core (software.amazon.awssdk:third-party-jackson-core:2.42.40 - https://aws.amazon.com/sdkforjava) + * AWS Java SDK :: Utilities (software.amazon.awssdk:utils:2.42.40 - https://aws.amazon.com/sdkforjava/utils) + * AWS Java SDK :: Utils Lite (software.amazon.awssdk:utils-lite:2.42.40 - https://aws.amazon.com/sdkforjava) + * software.amazon.awssdk.crt:aws-crt (software.amazon.awssdk.crt:aws-crt:0.45.1 - https://github.com/awslabs/aws-crt-java) + * AWS Event Stream (software.amazon.eventstream:eventstream:1.0.1 - https://github.com/awslabs/aws-eventstream-java) * Xerces2-j (xerces:xercesImpl:2.12.2 - https://xerces.apache.org/xerces2-j/) * XML Commons External Components XML APIs (xml-apis:xml-apis:1.4.01 - http://xml.apache.org/commons/components/external/) + BSD 2-Clause License: + + * zstd-jni (com.github.luben:zstd-jni:1.5.7-4 - https://github.com/luben/zstd-jni) + BSD License: * AntLR Parser Generator (antlr:antlr:2.7.7 - http://www.antlr.org/) @@ -471,15 +486,15 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines * Protocol Buffers [Core] (com.google.protobuf:protobuf-java:3.15.0 - https://developers.google.com/protocol-buffers/protobuf-java/) * JZlib (com.jcraft:jzlib:1.1.3 - http://www.jcraft.com/jzlib/) * jmustache (com.samskivert:jmustache:1.15 - http://github.com/samskivert/jmustache) - * dnsjava (dnsjava:dnsjava:3.6.3 - https://github.com/dnsjava/dnsjava) - * jaxen (jaxen:jaxen:2.0.0 - http://www.cafeconleche.org/jaxen/jaxen) + * dnsjava (dnsjava:dnsjava:3.6.4 - https://github.com/dnsjava/dnsjava) + * jaxen (jaxen:jaxen:2.0.1 - https://jaxen-xpath.github.io/jaxen/jaxen/) * ANTLR 4 Runtime (org.antlr:antlr4-runtime:4.5.1-1 - http://www.antlr.org/antlr4-runtime) * commons-compiler (org.codehaus.janino:commons-compiler:3.1.8 - http://janino-compiler.github.io/commons-compiler/) * janino (org.codehaus.janino:janino:3.1.8 - http://janino-compiler.github.io/janino/) * Stax2 API (org.codehaus.woodstox:stax2-api:4.2.1 - http://github.com/FasterXML/stax2-api) * Hamcrest Date (org.exparity:hamcrest-date:2.0.8 - https://github.com/exparity/hamcrest-date) - * jersey-core-client (org.glassfish.jersey.core:jersey-client:2.47 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-client) - * jersey-inject-hk2 (org.glassfish.jersey.inject:jersey-hk2:2.47 - https://projects.eclipse.org/projects/ee4j.jersey/project/jersey-hk2) + * jersey-core-client (org.glassfish.jersey.core:jersey-client:2.48 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-client) + * jersey-inject-hk2 (org.glassfish.jersey.inject:jersey-hk2:2.48 - https://projects.eclipse.org/projects/ee4j.jersey/project/jersey-hk2) * Hamcrest (org.hamcrest:hamcrest:2.2 - http://hamcrest.org/JavaHamcrest/) * Hamcrest Core (org.hamcrest:hamcrest-core:2.2 - http://hamcrest.org/JavaHamcrest/) * HdrHistogram (org.hdrhistogram:HdrHistogram:2.1.12 - http://hdrhistogram.github.io/HdrHistogram/) @@ -489,16 +504,12 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines * asm-commons (org.ow2.asm:asm-commons:9.3 - http://asm.ow2.io/) * ASM Tree (org.ow2.asm:asm-tree:5.0.3 - http://asm.objectweb.org/asm-tree/) * ASM Util (org.ow2.asm:asm-util:5.0.3 - http://asm.objectweb.org/asm-util/) - * PostgreSQL JDBC Driver (org.postgresql:postgresql:42.7.7 - https://jdbc.postgresql.org) + * PostgreSQL JDBC Driver (org.postgresql:postgresql:42.7.11 - https://jdbc.postgresql.org) * Reflections (org.reflections:reflections:0.9.12 - http://github.com/ronmamo/reflections) * JMatIO (org.tallison:jmatio:1.5 - https://github.com/tballison/jmatio) - * XZ for Java (org.tukaani:xz:1.10 - https://tukaani.org/xz/java.html) + * XZ for Java (org.tukaani:xz:1.12 - https://tukaani.org/xz/java.html) * XMLUnit for Java (xmlunit:xmlunit:1.3 - http://xmlunit.sourceforge.net/) - CC0: - - * reactive-streams (org.reactivestreams:reactive-streams:1.0.2 - http://www.reactive-streams.org/) - Common Development and Distribution License (CDDL): * JavaMail API (com.sun.mail:javax.mail:1.6.2 - http://javaee.github.io/javamail/javax.mail) @@ -514,15 +525,15 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines * Java Servlet API (javax.servlet:javax.servlet-api:3.1.0 - http://servlet-spec.java.net) * javax.transaction API (javax.transaction:javax.transaction-api:1.3 - http://jta-spec.java.net) * jaxb-api (javax.xml.bind:jaxb-api:2.3.1 - https://github.com/javaee/jaxb-spec/jaxb-api) - * JHighlight (org.codelibs:jhighlight:1.1.0 - https://github.com/codelibs/jhighlight) + * JHighlight (org.codelibs:jhighlight:1.1.1 - https://github.com/codelibs/jhighlight) * HK2 API module (org.glassfish.hk2:hk2-api:2.6.1 - https://github.com/eclipse-ee4j/glassfish-hk2/hk2-api) * ServiceLocator Default Implementation (org.glassfish.hk2:hk2-locator:2.6.1 - https://github.com/eclipse-ee4j/glassfish-hk2/hk2-locator) * HK2 Implementation Utilities (org.glassfish.hk2:hk2-utils:2.6.1 - https://github.com/eclipse-ee4j/glassfish-hk2/hk2-utils) * OSGi resource locator (org.glassfish.hk2:osgi-resource-locator:1.0.3 - https://projects.eclipse.org/projects/ee4j/osgi-resource-locator) * aopalliance version 1.0 repackaged as a module (org.glassfish.hk2.external:aopalliance-repackaged:2.6.1 - https://github.com/eclipse-ee4j/glassfish-hk2/external/aopalliance-repackaged) * javax.inject:1 as OSGi bundle (org.glassfish.hk2.external:jakarta.inject:2.6.1 - https://github.com/eclipse-ee4j/glassfish-hk2/external/jakarta.inject) - * jersey-core-client (org.glassfish.jersey.core:jersey-client:2.47 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-client) - * jersey-inject-hk2 (org.glassfish.jersey.inject:jersey-hk2:2.47 - https://projects.eclipse.org/projects/ee4j.jersey/project/jersey-hk2) + * jersey-core-client (org.glassfish.jersey.core:jersey-client:2.48 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-client) + * jersey-inject-hk2 (org.glassfish.jersey.inject:jersey-hk2:2.48 - https://projects.eclipse.org/projects/ee4j.jersey/project/jersey-hk2) * Java Transaction API (org.jboss.spec.javax.transaction:jboss-transaction-api_1.2_spec:1.1.1.Final - http://www.jboss.org/jboss-transaction-api_1.2_spec) Cordra (Version 2) License Agreement: @@ -543,8 +554,8 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines * javax.persistence-api (javax.persistence:javax.persistence-api:2.2 - https://github.com/javaee/jpa-spec) * JAXB Runtime (org.glassfish.jaxb:jaxb-runtime:2.3.9 - https://eclipse-ee4j.github.io/jaxb-ri/) * TXW2 Runtime (org.glassfish.jaxb:txw2:2.3.9 - https://eclipse-ee4j.github.io/jaxb-ri/) - * jersey-core-client (org.glassfish.jersey.core:jersey-client:2.47 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-client) - * jersey-inject-hk2 (org.glassfish.jersey.inject:jersey-hk2:2.47 - https://projects.eclipse.org/projects/ee4j.jersey/project/jersey-hk2) + * jersey-core-client (org.glassfish.jersey.core:jersey-client:2.48 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-client) + * jersey-inject-hk2 (org.glassfish.jersey.inject:jersey-hk2:2.48 - https://projects.eclipse.org/projects/ee4j.jersey/project/jersey-hk2) * Java Persistence API, Version 2.1 (org.hibernate.javax.persistence:hibernate-jpa-2.1-api:1.0.2.Final - http://hibernate.org) * org.locationtech.jts:jts-core (org.locationtech.jts:jts-core:1.19.0 - https://www.locationtech.org/projects/technology.jts/jts-modules/jts-core) * org.locationtech.jts.io:jts-io-common (org.locationtech.jts.io:jts-io-common:1.19.0 - https://www.locationtech.org/projects/technology.jts/jts-modules/jts-io/jts-io-common) @@ -552,7 +563,7 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines Eclipse Public License: * System Rules (com.github.stefanbirkner:system-rules:1.19.0 - http://stefanbirkner.github.io/system-rules/) - * H2 Database Engine (com.h2database:h2:2.3.232 - https://h2database.com) + * H2 Database Engine (com.h2database:h2:2.4.240 - https://h2database.com) * Jakarta Annotations API (jakarta.annotation:jakarta.annotation-api:1.3.5 - https://projects.eclipse.org/projects/ee4j.ca) * javax.transaction API (jakarta.transaction:jakarta.transaction-api:1.3.3 - https://projects.eclipse.org/projects/ee4j.jta) * jakarta.ws.rs-api (jakarta.ws.rs:jakarta.ws.rs-api:2.1.6 - https://github.com/eclipse-ee4j/jaxrs-api) @@ -564,34 +575,34 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines * Apache :: JSTL module (org.eclipse.jetty:apache-jstl:9.4.15.v20190215 - http://tomcat.apache.org/taglibs/standard/) * Jetty :: ALPN :: Client (org.eclipse.jetty:jetty-alpn-client:9.4.53.v20231009 - https://eclipse.org/jetty/jetty-alpn-parent/jetty-alpn-client) * Jetty :: ALPN :: JDK9 Client Implementation (org.eclipse.jetty:jetty-alpn-java-client:9.4.53.v20231009 - https://eclipse.org/jetty/jetty-alpn-parent/jetty-alpn-java-client) - * Jetty :: ALPN :: JDK9 Server Implementation (org.eclipse.jetty:jetty-alpn-java-server:9.4.57.v20241219 - https://jetty.org/jetty-alpn-parent/jetty-alpn-java-server/) + * Jetty :: ALPN :: JDK9 Server Implementation (org.eclipse.jetty:jetty-alpn-java-server:9.4.58.v20250814 - https://jetty.org/jetty-alpn-parent/jetty-alpn-java-server/) * Jetty :: ALPN :: Server (org.eclipse.jetty:jetty-alpn-server:9.4.53.v20231009 - https://eclipse.org/jetty/jetty-alpn-parent/jetty-alpn-server) - * Jetty :: ALPN :: Server (org.eclipse.jetty:jetty-alpn-server:9.4.57.v20241219 - https://jetty.org/jetty-alpn-parent/jetty-alpn-server/) + * Jetty :: ALPN :: Server (org.eclipse.jetty:jetty-alpn-server:9.4.58.v20250814 - https://jetty.org/jetty-alpn-parent/jetty-alpn-server/) * Jetty :: Servlet Annotations (org.eclipse.jetty:jetty-annotations:9.4.15.v20190215 - http://www.eclipse.org/jetty) * Jetty :: Asynchronous HTTP Client (org.eclipse.jetty:jetty-client:9.4.53.v20231009 - https://eclipse.org/jetty/jetty-client) * Jetty :: Continuation (org.eclipse.jetty:jetty-continuation:9.4.53.v20231009 - https://eclipse.org/jetty/jetty-continuation) - * Jetty :: Continuation (org.eclipse.jetty:jetty-continuation:9.4.57.v20241219 - https://jetty.org/jetty-continuation/) - * Jetty :: Deployers (org.eclipse.jetty:jetty-deploy:9.4.57.v20241219 - https://jetty.org/jetty-deploy/) - * Jetty :: Http Utility (org.eclipse.jetty:jetty-http:9.4.57.v20241219 - https://jetty.org/jetty-http/) - * Jetty :: IO Utility (org.eclipse.jetty:jetty-io:9.4.57.v20241219 - https://jetty.org/jetty-io/) + * Jetty :: Continuation (org.eclipse.jetty:jetty-continuation:9.4.58.v20250814 - https://jetty.org/jetty-continuation/) + * Jetty :: Deployers (org.eclipse.jetty:jetty-deploy:9.4.58.v20250814 - https://jetty.org/jetty-deploy/) + * Jetty :: Http Utility (org.eclipse.jetty:jetty-http:9.4.58.v20250814 - https://jetty.org/jetty-http/) + * Jetty :: IO Utility (org.eclipse.jetty:jetty-io:9.4.58.v20250814 - https://jetty.org/jetty-io/) * Jetty :: JMX Management (org.eclipse.jetty:jetty-jmx:9.4.53.v20231009 - https://eclipse.org/jetty/jetty-jmx) * Jetty :: JNDI Naming (org.eclipse.jetty:jetty-jndi:9.4.15.v20190215 - http://www.eclipse.org/jetty) * Jetty :: Plus (org.eclipse.jetty:jetty-plus:9.4.15.v20190215 - http://www.eclipse.org/jetty) * Jetty :: Rewrite Handler (org.eclipse.jetty:jetty-rewrite:9.4.53.v20231009 - https://eclipse.org/jetty/jetty-rewrite) * Jetty :: Security (org.eclipse.jetty:jetty-security:9.4.53.v20231009 - https://eclipse.org/jetty/jetty-security) - * Jetty :: Security (org.eclipse.jetty:jetty-security:9.4.57.v20241219 - https://jetty.org/jetty-security/) - * Jetty :: Server Core (org.eclipse.jetty:jetty-server:9.4.57.v20241219 - https://jetty.org/jetty-server/) - * Jetty :: Servlet Handling (org.eclipse.jetty:jetty-servlet:9.4.57.v20241219 - https://jetty.org/jetty-servlet/) - * Jetty :: Utility Servlets and Filters (org.eclipse.jetty:jetty-servlets:9.4.57.v20241219 - https://jetty.org/jetty-servlets/) - * Jetty :: Utilities (org.eclipse.jetty:jetty-util:9.4.57.v20241219 - https://jetty.org/jetty-util/) - * Jetty :: Utilities :: Ajax(JSON) (org.eclipse.jetty:jetty-util-ajax:9.4.57.v20241219 - https://jetty.org/jetty-util-ajax/) - * Jetty :: Webapp Application Support (org.eclipse.jetty:jetty-webapp:9.4.57.v20241219 - https://jetty.org/jetty-webapp/) - * Jetty :: XML utilities (org.eclipse.jetty:jetty-xml:9.4.57.v20241219 - https://jetty.org/jetty-xml/) + * Jetty :: Security (org.eclipse.jetty:jetty-security:9.4.58.v20250814 - https://jetty.org/jetty-security/) + * Jetty :: Server Core (org.eclipse.jetty:jetty-server:9.4.58.v20250814 - https://jetty.org/jetty-server/) + * Jetty :: Servlet Handling (org.eclipse.jetty:jetty-servlet:9.4.58.v20250814 - https://jetty.org/jetty-servlet/) + * Jetty :: Utility Servlets and Filters (org.eclipse.jetty:jetty-servlets:9.4.58.v20250814 - https://jetty.org/jetty-servlets/) + * Jetty :: Utilities (org.eclipse.jetty:jetty-util:9.4.58.v20250814 - https://jetty.org/jetty-util/) + * Jetty :: Utilities :: Ajax(JSON) (org.eclipse.jetty:jetty-util-ajax:9.4.58.v20250814 - https://jetty.org/jetty-util-ajax/) + * Jetty :: Webapp Application Support (org.eclipse.jetty:jetty-webapp:9.4.58.v20250814 - https://jetty.org/jetty-webapp/) + * Jetty :: XML utilities (org.eclipse.jetty:jetty-xml:9.4.58.v20250814 - https://jetty.org/jetty-xml/) * Jetty :: HTTP2 :: Client (org.eclipse.jetty.http2:http2-client:9.4.53.v20231009 - https://eclipse.org/jetty/http2-parent/http2-client) - * Jetty :: HTTP2 :: Common (org.eclipse.jetty.http2:http2-common:9.4.57.v20241219 - https://jetty.org/http2-parent/http2-common/) + * Jetty :: HTTP2 :: Common (org.eclipse.jetty.http2:http2-common:9.4.58.v20250814 - https://jetty.org/http2-parent/http2-common/) * Jetty :: HTTP2 :: HPACK (org.eclipse.jetty.http2:http2-hpack:9.4.53.v20231009 - https://eclipse.org/jetty/http2-parent/http2-hpack) * Jetty :: HTTP2 :: HTTP Client Transport (org.eclipse.jetty.http2:http2-http-client-transport:9.4.53.v20231009 - https://eclipse.org/jetty/http2-parent/http2-http-client-transport) - * Jetty :: HTTP2 :: Server (org.eclipse.jetty.http2:http2-server:9.4.57.v20241219 - https://jetty.org/http2-parent/http2-server/) + * Jetty :: HTTP2 :: Server (org.eclipse.jetty.http2:http2-server:9.4.58.v20250814 - https://jetty.org/http2-parent/http2-server/) * Jetty :: Schemas (org.eclipse.jetty.toolchain:jetty-schemas:3.1.2 - https://eclipse.org/jetty/jetty-schemas) * HK2 API module (org.glassfish.hk2:hk2-api:2.6.1 - https://github.com/eclipse-ee4j/glassfish-hk2/hk2-api) * ServiceLocator Default Implementation (org.glassfish.hk2:hk2-locator:2.6.1 - https://github.com/eclipse-ee4j/glassfish-hk2/hk2-locator) @@ -599,13 +610,10 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines * OSGi resource locator (org.glassfish.hk2:osgi-resource-locator:1.0.3 - https://projects.eclipse.org/projects/ee4j/osgi-resource-locator) * aopalliance version 1.0 repackaged as a module (org.glassfish.hk2.external:aopalliance-repackaged:2.6.1 - https://github.com/eclipse-ee4j/glassfish-hk2/external/aopalliance-repackaged) * javax.inject:1 as OSGi bundle (org.glassfish.hk2.external:jakarta.inject:2.6.1 - https://github.com/eclipse-ee4j/glassfish-hk2/external/jakarta.inject) - * jersey-core-client (org.glassfish.jersey.core:jersey-client:2.47 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-client) - * jersey-core-common (org.glassfish.jersey.core:jersey-common:2.47 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-common) - * jersey-inject-hk2 (org.glassfish.jersey.inject:jersey-hk2:2.47 - https://projects.eclipse.org/projects/ee4j.jersey/project/jersey-hk2) + * jersey-core-client (org.glassfish.jersey.core:jersey-client:2.48 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-client) + * jersey-core-common (org.glassfish.jersey.core:jersey-common:2.48 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-common) + * jersey-inject-hk2 (org.glassfish.jersey.inject:jersey-hk2:2.48 - https://projects.eclipse.org/projects/ee4j.jersey/project/jersey-hk2) * Java Persistence API, Version 2.1 (org.hibernate.javax.persistence:hibernate-jpa-2.1-api:1.0.2.Final - http://hibernate.org) - * JUnit Platform Commons (org.junit.platform:junit-platform-commons:1.11.4 - https://junit.org/junit5/) - * JUnit Platform Engine API (org.junit.platform:junit-platform-engine:1.11.4 - https://junit.org/junit5/) - * JUnit Vintage Engine (org.junit.vintage:junit-vintage-engine:5.11.4 - https://junit.org/junit5/) * org.locationtech.jts:jts-core (org.locationtech.jts:jts-core:1.19.0 - https://www.locationtech.org/projects/technology.jts/jts-modules/jts-core) * org.locationtech.jts.io:jts-io-common (org.locationtech.jts.io:jts-io-common:1.19.0 - https://www.locationtech.org/projects/technology.jts/jts-modules/jts-io/jts-io-common) * Jetty Server (org.mortbay.jetty:jetty:6.1.26 - http://www.eclipse.org/jetty/jetty-parent/project/modules/jetty) @@ -631,14 +639,14 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines * msg-simple (com.github.java-json-tools:msg-simple:1.2 - https://github.com/java-json-tools/msg-simple) * uri-template (com.github.java-json-tools:uri-template:0.10 - https://github.com/java-json-tools/uri-template) * FindBugs-Annotations (com.google.code.findbugs:annotations:3.0.1u2 - http://findbugs.sourceforge.net/) - * JHighlight (org.codelibs:jhighlight:1.1.0 - https://github.com/codelibs/jhighlight) + * JHighlight (org.codelibs:jhighlight:1.1.1 - https://github.com/codelibs/jhighlight) * Hibernate ORM - hibernate-core (org.hibernate:hibernate-core:5.6.15.Final - https://hibernate.org/orm) * Hibernate ORM - hibernate-jcache (org.hibernate:hibernate-jcache:5.6.15.Final - https://hibernate.org/orm) * Hibernate ORM - hibernate-jpamodelgen (org.hibernate:hibernate-jpamodelgen:5.6.15.Final - https://hibernate.org/orm) * Hibernate Commons Annotations (org.hibernate.common:hibernate-commons-annotations:5.1.2.Final - http://hibernate.org) * im4java (org.im4java:im4java:1.4.0 - http://sourceforge.net/projects/im4java/) * Javassist (org.javassist:javassist:3.30.2-GA - https://www.javassist.org/) - * XOM (xom:xom:1.3.9 - https://xom.nu) + * XOM (xom:xom:1.4.0 - https://xom.nu) Go License: @@ -652,29 +660,34 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines * Simple Magic (com.j256.simplemagic:simplemagic:1.17 - https://256stuff.com/sources/simplemagic/) + LGPL-2.1-or-later: + + * Java Native Access (net.java.dev.jna:jna:5.18.1 - https://github.com/java-native-access/jna) + MIT License: - * better-files (com.github.pathikrit:better-files_2.13:3.9.1 - https://github.com/pathikrit/better-files) * Java SemVer (com.github.zafarkhaja:java-semver:0.9.0 - https://github.com/zafarkhaja/jsemver) - * dd-plist (com.googlecode.plist:dd-plist:1.28 - http://www.github.com/3breadt/dd-plist) + * dd-plist (com.googlecode.plist:dd-plist:1.29 - http://www.github.com/3breadt/dd-plist) * DigitalCollections: IIIF API Library (de.digitalcollections.iiif:iiif-apis:0.3.11 - https://github.com/dbmdz/iiif-apis) - * s3mock (io.findify:s3mock_2.13:0.2.6 - https://github.com/findify/s3mock) * ClassGraph (io.github.classgraph:classgraph:4.8.154 - https://github.com/classgraph/classgraph) * JOpt Simple (net.sf.jopt-simple:jopt-simple:5.0.4 - http://jopt-simple.github.io/jopt-simple) - * Bouncy Castle JavaMail S/MIME APIs (org.bouncycastle:bcmail-jdk18on:1.80 - https://www.bouncycastle.org/download/bouncy-castle-java/) + * Bouncy Castle JavaMail Jakarta S/MIME APIs (org.bouncycastle:bcjmail-jdk18on:1.83 - https://www.bouncycastle.org/download/bouncy-castle-java/) * Bouncy Castle PKIX, CMS, EAC, TSP, PKCS, OCSP, CMP, and CRMF APIs (org.bouncycastle:bcpkix-jdk18on:1.81 - https://www.bouncycastle.org/download/bouncy-castle-java/) * Bouncy Castle Provider (org.bouncycastle:bcprov-jdk18on:1.81 - https://www.bouncycastle.org/download/bouncy-castle-java/) * Bouncy Castle ASN.1 Extension and Utility APIs (org.bouncycastle:bcutil-jdk18on:1.81 - https://www.bouncycastle.org/download/bouncy-castle-java/) * org.brotli:dec (org.brotli:dec:0.1.2 - http://brotli.org/dec) * Checker Qual (org.checkerframework:checker-qual:3.23.0 - https://checkerframework.org) - * Checker Qual (org.checkerframework:checker-qual:3.49.3 - https://checkerframework.org/) - * jersey-core-client (org.glassfish.jersey.core:jersey-client:2.47 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-client) - * jersey-inject-hk2 (org.glassfish.jersey.inject:jersey-hk2:2.47 - https://projects.eclipse.org/projects/ee4j.jersey/project/jersey-hk2) + * jersey-core-client (org.glassfish.jersey.core:jersey-client:2.48 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-client) + * jersey-inject-hk2 (org.glassfish.jersey.inject:jersey-hk2:2.48 - https://projects.eclipse.org/projects/ee4j.jersey/project/jersey-hk2) + * jsoup Java HTML Parser (org.jsoup:jsoup:1.22.1 - https://jsoup.org/) * mockito-core (org.mockito:mockito-core:3.12.4 - https://github.com/mockito/mockito) * mockito-inline (org.mockito:mockito-inline:3.12.4 - https://github.com/mockito/mockito) * ORCID - Model (org.orcid:orcid-model:3.0.2 - http://github.com/ORCID/orcid-model) + * Duct Tape (org.rnorth.duct-tape:duct-tape:1.0.8 - https://github.com/rnorth/duct-tape) * JUL to SLF4J bridge (org.slf4j:jul-to-slf4j:1.7.36 - http://www.slf4j.org) * SLF4J API Module (org.slf4j:slf4j-api:1.7.36 - http://www.slf4j.org) + * Testcontainers Core (org.testcontainers:testcontainers:2.0.5 - https://java.testcontainers.org) + * Testcontainers :: Localstack (org.testcontainers:testcontainers-localstack:2.0.5 - https://java.testcontainers.org) * HAL Browser (org.webjars:hal-browser:ad9b865 - http://webjars.org) * toastr (org.webjars.bowergithub.codeseven:toastr:2.1.4 - http://webjars.org) * backbone (org.webjars.bowergithub.jashkenas:backbone:1.4.1 - https://www.webjars.org) @@ -682,30 +695,38 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines * jquery (org.webjars.bowergithub.jquery:jquery-dist:3.7.1 - https://www.webjars.org) * urijs (org.webjars.bowergithub.medialize:uri.js:1.19.11 - https://www.webjars.org) * bootstrap (org.webjars.bowergithub.twbs:bootstrap:4.6.2 - https://www.webjars.org) - * core-js (org.webjars.npm:core-js:3.42.0 - https://www.webjars.org) + * core-js (org.webjars.npm:core-js:3.49.0 - https://www.webjars.org) * @json-editor/json-editor (org.webjars.npm:json-editor__json-editor:2.15.2 - https://www.webjars.org) + MIT-0: + + * reactive-streams (org.reactivestreams:reactive-streams:1.0.4 - http://www.reactive-streams.org/) + Mozilla Public License: * juniversalchardet (com.github.albfernandez:juniversalchardet:2.5.0 - https://github.com/albfernandez/juniversalchardet) - * H2 Database Engine (com.h2database:h2:2.3.232 - https://h2database.com) + * H2 Database Engine (com.h2database:h2:2.4.240 - https://h2database.com) * Saxon-HE (net.sf.saxon:Saxon-HE:9.9.1-8 - http://www.saxonica.com/) * Javassist (org.javassist:javassist:3.30.2-GA - https://www.javassist.org/) * Mozilla Rhino (org.mozilla:rhino:1.7.7.2 - https://developer.mozilla.org/en/Rhino) Public Domain: - * jersey-core-client (org.glassfish.jersey.core:jersey-client:2.47 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-client) - * jersey-core-common (org.glassfish.jersey.core:jersey-common:2.47 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-common) - * jersey-inject-hk2 (org.glassfish.jersey.inject:jersey-hk2:2.47 - https://projects.eclipse.org/projects/ee4j.jersey/project/jersey-hk2) + * jersey-core-client (org.glassfish.jersey.core:jersey-client:2.48 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-client) + * jersey-core-common (org.glassfish.jersey.core:jersey-common:2.48 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-common) + * jersey-inject-hk2 (org.glassfish.jersey.inject:jersey-hk2:2.48 - https://projects.eclipse.org/projects/ee4j.jersey/project/jersey-hk2) * HdrHistogram (org.hdrhistogram:HdrHistogram:2.1.12 - http://hdrhistogram.github.io/HdrHistogram/) * JSON in Java (org.json:json:20231013 - https://github.com/douglascrockford/JSON-java) * LatencyUtils (org.latencyutils:LatencyUtils:2.0.3 - http://latencyutils.github.io/LatencyUtils/) * Reflections (org.reflections:reflections:0.9.12 - http://github.com/ronmamo/reflections) + The Apache Software License, version 2.0: + + * picocli (info.picocli:picocli:4.7.7 - https://picocli.info) + UnRar License: - * Java Unrar (com.github.junrar:junrar:7.5.5 - https://github.com/junrar/junrar) + * Java Unrar (com.github.junrar:junrar:7.5.8 - https://github.com/junrar/junrar) Unicode/ICU License: @@ -713,10 +734,10 @@ https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines W3C license: - * jersey-core-client (org.glassfish.jersey.core:jersey-client:2.47 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-client) - * jersey-inject-hk2 (org.glassfish.jersey.inject:jersey-hk2:2.47 - https://projects.eclipse.org/projects/ee4j.jersey/project/jersey-hk2) + * jersey-core-client (org.glassfish.jersey.core:jersey-client:2.48 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-client) + * jersey-inject-hk2 (org.glassfish.jersey.inject:jersey-hk2:2.48 - https://projects.eclipse.org/projects/ee4j.jersey/project/jersey-hk2) jQuery license: - * jersey-core-client (org.glassfish.jersey.core:jersey-client:2.47 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-client) - * jersey-inject-hk2 (org.glassfish.jersey.inject:jersey-hk2:2.47 - https://projects.eclipse.org/projects/ee4j.jersey/project/jersey-hk2) + * jersey-core-client (org.glassfish.jersey.core:jersey-client:2.48 - https://projects.eclipse.org/projects/ee4j.jersey/jersey-client) + * jersey-inject-hk2 (org.glassfish.jersey.inject:jersey-hk2:2.48 - https://projects.eclipse.org/projects/ee4j.jersey/project/jersey-hk2) diff --git a/docker-compose-cli.yml b/docker-compose-cli.yml index d6a194617e0..df97629d0c5 100644 --- a/docker-compose-cli.yml +++ b/docker-compose-cli.yml @@ -19,9 +19,9 @@ services: # dspace.dir: Must match with Dockerfile's DSPACE_INSTALL directory. dspace__P__dir: /dspace # db.url: Ensure we are using the 'dspacedb' image for our database - db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace' + db__P__url: ${db__P__url:-jdbc:postgresql://dspacedb:5432/dspace} # solr.server: Ensure we are using the 'dspacesolr' image for Solr - solr__P__server: http://dspacesolr:8983/solr + solr__P__server: ${solr__P__server:-http://dspacesolr:8983/solr} volumes: # Keep DSpace assetstore directory between reboots - assetstore:/dspace/assetstore @@ -30,8 +30,6 @@ services: - ./dspace/config:/dspace/config entrypoint: /dspace/bin/dspace command: help - tty: true - stdin_open: true volumes: assetstore: diff --git a/docker-compose.yml b/docker-compose.yml index a7894135c6a..b94b31b15fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,8 @@ networks: # Define a custom subnet for our DSpace network, so that we can easily trust requests from host to container. # If you customize this value, be sure to customize the 'proxies.trusted.ipranges' env variable below. - subnet: 172.23.0.0/16 + # Explicitly set external=false because this script creates the network. + external: false services: # DSpace (backend) webapp container dspace: @@ -20,15 +22,18 @@ services: # Uncomment to set a non-default value for dspace.server.url or dspace.ui.url # dspace__P__server__P__url: http://localhost:8080/server # dspace__P__ui__P__url: http://localhost:4000 - dspace__P__name: 'DSpace Started with Docker Compose' + # Set SSR URL to the Docker container name so that UI can contact container directly in Production mode. + # (This is necessary for docker-compose-angular.yml as it uses production mode by default) + dspace__P__server__P__ssr__P__url: ${dspace__P__server__P__ssr__P__url:-http://dspace:8080/server} + dspace__P__name: ${dspace__P__name:-DSpace Started with Docker Compose} # db.url: Ensure we are using the 'dspacedb' image for our database - db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace' + db__P__url: ${db__P__url:-jdbc:postgresql://dspacedb:5432/dspace} # solr.server: Ensure we are using the 'dspacesolr' image for Solr - solr__P__server: http://dspacesolr:8983/solr + solr__P__server: ${solr__P__server:-http://dspacesolr:8983/solr} # proxies.trusted.ipranges: This setting is required for a REST API running in Docker to trust requests # 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 + proxies__P__trusted__P__ipranges: ${proxies__P__trusted__P__ipranges:-172.23.0} + LOGGING_CONFIG: ${LOGGING_CONFIG:-/dspace/config/log4j2-container.xml} image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-dspace-7_x-test}" build: context: . @@ -44,8 +49,6 @@ services: target: 8009 - published: 8000 target: 8000 - stdin_open: true - tty: true volumes: # Keep DSpace assetstore directory between reboots - assetstore:/dspace/assetstore @@ -79,8 +82,6 @@ services: ports: - published: 5432 target: 5432 - stdin_open: true - tty: true volumes: # Keep Postgres data directory between reboots - pgdata:/pgdata @@ -100,8 +101,6 @@ services: ports: - published: 8983 target: 8983 - stdin_open: true - tty: true working_dir: /var/solr/data volumes: # Keep Solr data directory between reboots diff --git a/dspace-api/pom.xml b/dspace-api/pom.xml index e2f66f0b81b..fbe0c946bbd 100644 --- a/dspace-api/pom.xml +++ b/dspace-api/pom.xml @@ -12,7 +12,7 @@ org.dspace dspace-parent - 7.6.5 + 7.6.7 .. @@ -99,24 +99,10 @@ - - org.codehaus.mojo - build-helper-maven-plugin - 3.6.1 - - - validate - - maven-version - - - - - org.codehaus.mojo buildnumber-maven-plugin - 3.2.1 + 3.3.0 UNKNOWN_REVISION @@ -647,7 +633,7 @@ dnsjava dnsjava - 3.6.3 + 3.6.4 @@ -656,6 +642,7 @@ 1.1.1 + com.google.guava guava @@ -755,9 +742,25 @@ - com.amazonaws - aws-java-sdk-s3 - 1.12.785 + software.amazon.awssdk + s3 + 2.42.40 + + + software.amazon.awssdk + netty-nio-client + + + software.amazon.awssdk + apache-client + + + + + + software.amazon.awssdk.crt + aws-crt + 0.45.1 @@ -806,7 +809,7 @@ com.opencsv opencsv - 5.11.1 + 5.12.0 @@ -824,7 +827,7 @@ org.apache.bcel bcel - 6.10.0 + 6.12.0 test @@ -872,22 +875,13 @@ - + + - io.findify - s3mock_2.13 - 0.2.6 - test - - - com.amazonawsl - aws-java-sdk-s3 - - - com.amazonaws - aws-java-sdk-s3 - - + org.testcontainers + testcontainers-localstack + 2.0.5 + test @@ -930,32 +924,32 @@ io.netty netty-buffer - 4.2.2.Final + 4.2.12.Final io.netty netty-transport - 4.2.2.Final + 4.2.12.Final io.netty netty-transport-native-unix-common - 4.2.2.Final + 4.2.12.Final io.netty netty-common - 4.2.2.Final + 4.2.12.Final io.netty netty-handler - 4.2.2.Final + 4.2.12.Final io.netty netty-codec - 4.2.2.Final + 4.2.12.Final org.apache.velocity @@ -965,7 +959,7 @@ org.xmlunit xmlunit-core - 2.10.2 + 2.11.0 test @@ -986,7 +980,7 @@ org.scala-lang scala-library - 2.13.16 + 2.13.18 test diff --git a/dspace-api/src/main/java/org/dspace/app/bulkaccesscontrol/BulkAccessControl.java b/dspace-api/src/main/java/org/dspace/app/bulkaccesscontrol/BulkAccessControl.java index 333aa995e9a..afeb3980377 100644 --- a/dspace-api/src/main/java/org/dspace/app/bulkaccesscontrol/BulkAccessControl.java +++ b/dspace-api/src/main/java/org/dspace/app/bulkaccesscontrol/BulkAccessControl.java @@ -18,6 +18,7 @@ import java.sql.SQLException; import java.text.DateFormat; import java.text.SimpleDateFormat; +import java.time.ZoneOffset; import java.util.Arrays; import java.util.Date; import java.util.Iterator; @@ -157,7 +158,7 @@ public void internalRun() throws Exception { } ObjectMapper mapper = new ObjectMapper(); - mapper.setTimeZone(TimeZone.getTimeZone("UTC")); + mapper.setTimeZone(TimeZone.getTimeZone(ZoneOffset.UTC)); BulkAccessControlInput accessControl; context = new Context(Context.Mode.BATCH_EDIT); setEPerson(context); diff --git a/dspace-api/src/main/java/org/dspace/app/bulkedit/DSpaceCSV.java b/dspace-api/src/main/java/org/dspace/app/bulkedit/DSpaceCSV.java index 3533a2397b3..89caa6c1528 100644 --- a/dspace-api/src/main/java/org/dspace/app/bulkedit/DSpaceCSV.java +++ b/dspace-api/src/main/java/org/dspace/app/bulkedit/DSpaceCSV.java @@ -25,6 +25,7 @@ import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; +import org.dspace.app.util.MetadataExposureServiceImpl; import org.dspace.authority.AuthorityValue; import org.dspace.authority.factory.AuthorityServiceFactory; import org.dspace.authority.service.AuthorityValueService; @@ -321,20 +322,7 @@ protected void init() { // Set the metadata fields to ignore ignore = new HashMap<>(); - // Specify default values - String[] defaultValues = - new String[] { - "dc.date.accessioned", "dc.date.available", "dc.date.updated", "dc.description.provenance" - }; - String[] toIgnoreArray = - DSpaceServicesFactory.getInstance() - .getConfigurationService() - .getArrayProperty("bulkedit.ignore-on-export", defaultValues); - for (String toIgnoreString : toIgnoreArray) { - if (!"".equals(toIgnoreString.trim())) { - ignore.put(toIgnoreString.trim(), toIgnoreString.trim()); - } - } + getConfiguredIgnoreFields(); } /** @@ -352,6 +340,40 @@ public boolean hasActions() { return false; } + /** + * Sets the ignored fields with 'bulkedit.ignore-on-export' + * + * Also adds 'metadata.hide.*' fields to ignored if 'bulkedit.ignore-on-export.include-metadata-hide' is true + */ + private void getConfiguredIgnoreFields() { + // Specify default values + String[] defaultValues = + new String[] { + "dc.date.accessioned", "dc.date.available", "dc.date.updated", "dc.description.provenance" + }; + String[] toIgnoreArray = + DSpaceServicesFactory.getInstance() + .getConfigurationService() + .getArrayProperty("bulkedit.ignore-on-export", defaultValues); + + boolean ignoreHiddenMetadata = DSpaceServicesFactory.getInstance().getConfigurationService() + .getBooleanProperty("bulkedit.ignore-on-export.include-metadata-hide", true); + if (ignoreHiddenMetadata) { + List hiddenMetadata = DSpaceServicesFactory.getInstance().getConfigurationService() + .getPropertyKeys(MetadataExposureServiceImpl.CONFIG_PREFIX); + for (String hiddenMetadataKey : hiddenMetadata) { + String key = hiddenMetadataKey.split(MetadataExposureServiceImpl.CONFIG_PREFIX)[1]; + ignore.put(key.trim(), key.trim()); + } + } + + for (String toIgnoreString : toIgnoreArray) { + if (!"".equals(toIgnoreString.trim())) { + ignore.put(toIgnoreString.trim(), toIgnoreString.trim()); + } + } + } + /** * Set the value separator for multiple values stored in one csv value. * diff --git a/dspace-api/src/main/java/org/dspace/app/bulkedit/MetadataExportSearch.java b/dspace-api/src/main/java/org/dspace/app/bulkedit/MetadataExportSearch.java index e4bbe335d63..689df4701a9 100644 --- a/dspace-api/src/main/java/org/dspace/app/bulkedit/MetadataExportSearch.java +++ b/dspace-api/src/main/java/org/dspace/app/bulkedit/MetadataExportSearch.java @@ -14,6 +14,8 @@ import java.util.List; import java.util.UUID; +import org.apache.commons.cli.DefaultParser; +import org.apache.commons.cli.DefaultParser.Builder; import org.apache.commons.cli.ParseException; import org.dspace.content.Item; import org.dspace.content.MetadataDSpaceCsvExportServiceImpl; @@ -167,4 +169,14 @@ public IndexableObject resolveScope(Context context, String id) throws SQLExcept } return scopeObj; } + + @Override + protected StepResult parse(String[] args) throws ParseException { + commandLine = new DefaultParser().parse(getScriptConfiguration().getOptions(), args); + Builder builder = new DefaultParser().builder(); + builder.setStripLeadingAndTrailingQuotes(false); + commandLine = builder.build().parse(getScriptConfiguration().getOptions(), args); + setup(); + return StepResult.Continue; + } } diff --git a/dspace-api/src/main/java/org/dspace/app/bulkedit/MetadataImport.java b/dspace-api/src/main/java/org/dspace/app/bulkedit/MetadataImport.java index ad46cb95c35..fb5657a9e3c 100644 --- a/dspace-api/src/main/java/org/dspace/app/bulkedit/MetadataImport.java +++ b/dspace-api/src/main/java/org/dspace/app/bulkedit/MetadataImport.java @@ -358,6 +358,16 @@ public List runImport(Context c, boolean change, // Process each change rowCount = 1; + + int maxItems = configurationService.getIntProperty("bulkedit.import.max.items", 1000); + int numItems = toImport.size(); + if (numItems > maxItems && maxItems > 0) { + throw new MetadataImportException( + "Import contains " + numItems + " items, which exceeds the configured " + + "maximum of " + maxItems + ". You can change this limit by setting " + + "'bulkedit.import.max.items' in your local configuration."); + } + for (DSpaceCSVLine line : toImport) { // Resolve target references to other items populateRefAndRowMap(line, line.getID()); @@ -494,7 +504,7 @@ public List runImport(Context c, boolean change, // Check it has an owning collection List collections = line.get("collection"); - if (collections == null) { + if (collections == null || collections.isEmpty()) { throw new MetadataImportException( "New items must have a 'collection' assigned in the form of a handle"); } diff --git a/dspace-api/src/main/java/org/dspace/app/checker/ChecksumChecker.java b/dspace-api/src/main/java/org/dspace/app/checker/ChecksumChecker.java index ec024c34526..160d23e3220 100644 --- a/dspace-api/src/main/java/org/dspace/app/checker/ChecksumChecker.java +++ b/dspace-api/src/main/java/org/dspace/app/checker/ChecksumChecker.java @@ -98,7 +98,7 @@ public static void main(String[] args) throws SQLException { options.addOption("h", "help", false, "Help"); options.addOption("d", "duration", true, "Checking duration"); options.addOption("c", "count", true, "Check count"); - options.addOption("a", "handle", true, "Specify a handle to check"); + options.addOption("i", "handle", true, "Specify a handle to check"); options.addOption("v", "verbose", false, "Report all processing"); Option option; @@ -106,7 +106,7 @@ public static void main(String[] args) throws SQLException { option = Option.builder("b") .longOpt("bitstream-ids") .hasArgs() - .desc("Space separated list of bitstream ids") + .desc("Space separated list of bitstream UUIDs") .build(); options.addOption(option); @@ -132,6 +132,17 @@ public static void main(String[] args) throws SQLException { try { context = new Context(); + int mutuallyExclusiveOpts = 0; + for (char c : new char[]{'l', 'L', 'd', 'b', 'i','c'}) { + if (line.hasOption(c)) { + mutuallyExclusiveOpts++; + } + } + if (mutuallyExclusiveOpts > 1) { + System.err.println("Please use only one option of -l, -L, -d, -b, -i, or -c"); + LOG.error("Please use only one option of -l, -L, -d, -b, -i, or -c"); + System.exit(1); + } // Prune stage if (line.hasOption('p')) { @@ -169,13 +180,13 @@ public static void main(String[] args) throws SQLException { bitstreams.add(bitstreamService.find(context, UUID.fromString(ids[i]))); } catch (NumberFormatException nfe) { System.err.println("The following argument: " + ids[i] - + " is not an integer"); + + " is not an UUID"); System.exit(0); } } dispatcher = new IteratorDispatcher(bitstreams.iterator()); - } else if (line.hasOption('a')) { - dispatcher = new HandleDispatcher(context, line.getOptionValue('a')); + } else if (line.hasOption('i')) { + dispatcher = new HandleDispatcher(context, line.getOptionValue('i')); } else if (line.hasOption('d')) { // run checker process for specified duration try { @@ -185,6 +196,8 @@ public static void main(String[] args) throws SQLException { + Utils.parseDuration(line .getOptionValue('d')))); } catch (Exception e) { + System.err.println("Couldn't parse " + line.getOptionValue('d') + + " as a duration"); LOG.fatal("Couldn't parse " + line.getOptionValue('d') + " as a duration: ", e); System.exit(0); @@ -228,18 +241,24 @@ public static void main(String[] args) throws SQLException { private static void printHelp(Options options) { HelpFormatter myhelp = new HelpFormatter(); - myhelp.printHelp("Checksum Checker\n", options); - System.out.println("\nSpecify a duration for checker process, using s(seconds)," - + "m(minutes), or h(hours): ChecksumChecker -d 30s" - + " OR ChecksumChecker -d 30m" - + " OR ChecksumChecker -d 2h"); - System.out.println("\nSpecify bitstream IDs: ChecksumChecker -b 13 15 17 20"); - System.out.println("\nLoop once through all bitstreams: " - + "ChecksumChecker -l"); - System.out.println("\nLoop continuously through all bitstreams: ChecksumChecker -L"); - System.out.println("\nCheck a defined number of bitstreams: ChecksumChecker -c 10"); - System.out.println("\nReport all processing (verbose)(default reports only errors): ChecksumChecker -v"); - System.out.println("\nDefault (no arguments) is equivalent to '-c 1'"); + myhelp.printHelp("checker\n", options); + System.out.println("\nChecksum Checker usage examples:"); + System.out.println("\nThe following options are mutually exclusive:"); + System.out.println(" - Specify a duration for checker process, using s(seconds)," + + "m(minutes), or h(hours): checker -d 30s" + + " OR checker -d 30m" + + " OR checker -d 2h"); + System.out.println(" - Specify bitstream UUIDs: checker -b 550e8400-e29b-41d4-a716-446655440000" + + " f3f2e850-b5d4-11ef-ac7e-96584d5248b2"); + System.out.println(" - Specify handle: checker -i 12345/100"); + System.out.println(" - Loop once through all bitstreams: " + + "checker -l"); + System.out.println(" - Loop continuously through all bitstreams: checker -L"); + System.out.println(" - Check a defined number of bitstreams: checker -c 10"); + System.out.println("\nThe following options can be used in combination with others above:"); + System.out.println(" - Report all processing to checker.log (by default logs only errors): checker -v"); + System.out.println(" - Prune old results from the database: checker -p"); + System.out.println("\nDefault (no arguments) is equivalent to 'checker -c 1'\n"); System.exit(0); } diff --git a/dspace-api/src/main/java/org/dspace/app/itemexport/ItemExportServiceImpl.java b/dspace-api/src/main/java/org/dspace/app/itemexport/ItemExportServiceImpl.java index 7c80e1ea7dc..e5ca8fe688c 100644 --- a/dspace-api/src/main/java/org/dspace/app/itemexport/ItemExportServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/app/itemexport/ItemExportServiceImpl.java @@ -352,7 +352,7 @@ protected void writeHandle(Context c, Item i, File destDir) /** * Create the 'collections' file. List handles of all Collections which - * contain this Item. The "owning" Collection is listed first. + * contain this Item. The "owning" Collection is listed first. * * @param item list collections holding this Item. * @param destDir write the file here. @@ -363,12 +363,14 @@ protected void writeCollections(Item item, File destDir) File outFile = new File(destDir, "collections"); if (outFile.createNewFile()) { try (PrintWriter out = new PrintWriter(new FileWriter(outFile))) { - String ownerHandle = item.getOwningCollection().getHandle(); - out.println(ownerHandle); + Collection owningCollection = item.getOwningCollection(); + // The owning collection is null for workspace and workflow items + if (owningCollection != null) { + out.println(owningCollection.getHandle()); + } for (Collection collection : item.getCollections()) { - String collectionHandle = collection.getHandle(); - if (!collectionHandle.equals(ownerHandle)) { - out.println(collectionHandle); + if (!collection.equals(owningCollection)) { + out.println(collection.getHandle()); } } } diff --git a/dspace-api/src/main/java/org/dspace/app/itemimport/ItemImport.java b/dspace-api/src/main/java/org/dspace/app/itemimport/ItemImport.java index b32de11f7a7..33487bc8e35 100644 --- a/dspace-api/src/main/java/org/dspace/app/itemimport/ItemImport.java +++ b/dspace-api/src/main/java/org/dspace/app/itemimport/ItemImport.java @@ -22,6 +22,7 @@ import org.apache.commons.cli.ParseException; import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.apache.tika.Tika; import org.dspace.app.itemimport.factory.ItemImportServiceFactory; @@ -333,33 +334,38 @@ protected void process(Context context, ItemImportService itemImportService, protected void readZip(Context context, ItemImportService itemImportService) throws Exception { Optional optionalFileStream = Optional.empty(); Optional validationFileStream = Optional.empty(); - if (!remoteUrl) { - // manage zip via upload - optionalFileStream = handler.getFileStream(context, zipfilename); - validationFileStream = handler.getFileStream(context, zipfilename); - } else { - // manage zip via remote url - optionalFileStream = Optional.ofNullable(new URL(zipfilename).openStream()); - validationFileStream = Optional.ofNullable(new URL(zipfilename).openStream()); - } + try { + if (!remoteUrl) { + // manage zip via upload + optionalFileStream = handler.getFileStream(context, zipfilename); + validationFileStream = handler.getFileStream(context, zipfilename); + } else { + // manage zip via remote url + optionalFileStream = Optional.ofNullable(new URL(zipfilename).openStream()); + validationFileStream = Optional.ofNullable(new URL(zipfilename).openStream()); + } - if (validationFileStream.isPresent()) { - // validate zip file if (validationFileStream.isPresent()) { - validateZip(validationFileStream.get()); + // validate zip file + if (validationFileStream.isPresent()) { + validateZip(validationFileStream.get()); + } + + workFile = new File(itemImportService.getTempWorkDir() + File.separator + + zipfilename + "-" + context.getCurrentUser().getID()); + FileUtils.copyInputStreamToFile(optionalFileStream.get(), workFile); + } else { + throw new IllegalArgumentException( + "Error reading file, the file couldn't be found for filename: " + zipfilename); } - workFile = new File(itemImportService.getTempWorkDir() + File.separator - + zipfilename + "-" + context.getCurrentUser().getID()); - FileUtils.copyInputStreamToFile(optionalFileStream.get(), workFile); - } else { - throw new IllegalArgumentException( - "Error reading file, the file couldn't be found for filename: " + zipfilename); + workDir = new File(itemImportService.getTempWorkDir() + File.separator + TEMP_DIR + + File.separator + context.getCurrentUser().getID()); + sourcedir = itemImportService.unzip(workFile, workDir.getAbsolutePath()); + } finally { + optionalFileStream.ifPresent(IOUtils::closeQuietly); + validationFileStream.ifPresent(IOUtils::closeQuietly); } - - workDir = new File(itemImportService.getTempWorkDir() + File.separator + TEMP_DIR - + File.separator + context.getCurrentUser().getID()); - sourcedir = itemImportService.unzip(workFile, workDir.getAbsolutePath()); } /** diff --git a/dspace-api/src/main/java/org/dspace/app/itemimport/ItemImportCLI.java b/dspace-api/src/main/java/org/dspace/app/itemimport/ItemImportCLI.java index 98d2469b715..bd29aa97fe4 100644 --- a/dspace-api/src/main/java/org/dspace/app/itemimport/ItemImportCLI.java +++ b/dspace-api/src/main/java/org/dspace/app/itemimport/ItemImportCLI.java @@ -17,6 +17,7 @@ import java.util.UUID; import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.dspace.app.itemimport.service.ItemImportService; import org.dspace.content.Collection; @@ -111,7 +112,11 @@ protected void readZip(Context context, ItemImportService itemImportService) thr // validate zip file InputStream validationFileStream = new FileInputStream(myZipFile); - validateZip(validationFileStream); + try { + validateZip(validationFileStream); + } finally { + IOUtils.closeQuietly(validationFileStream); + } workDir = new File(itemImportService.getTempWorkDir() + File.separator + TEMP_DIR + File.separator + context.getCurrentUser().getID()); @@ -120,22 +125,28 @@ protected void readZip(Context context, ItemImportService itemImportService) thr } else { // manage zip via remote url Optional optionalFileStream = Optional.ofNullable(new URL(zipfilename).openStream()); - if (optionalFileStream.isPresent()) { - // validate zip file via url - Optional validationFileStream = Optional.ofNullable(new URL(zipfilename).openStream()); - if (validationFileStream.isPresent()) { - validateZip(validationFileStream.get()); + Optional validationFileStream = Optional.ofNullable(new URL(zipfilename).openStream()); + try { + if (optionalFileStream.isPresent()) { + // validate zip file via url + + if (validationFileStream.isPresent()) { + validateZip(validationFileStream.get()); + } + + workFile = new File(itemImportService.getTempWorkDir() + File.separator + + zipfilename + "-" + context.getCurrentUser().getID()); + FileUtils.copyInputStreamToFile(optionalFileStream.get(), workFile); + workDir = new File(itemImportService.getTempWorkDir() + File.separator + TEMP_DIR + + File.separator + context.getCurrentUser().getID()); + sourcedir = itemImportService.unzip(workFile, workDir.getAbsolutePath()); + } else { + throw new IllegalArgumentException( + "Error reading file, the file couldn't be found for filename: " + zipfilename); } - - workFile = new File(itemImportService.getTempWorkDir() + File.separator - + zipfilename + "-" + context.getCurrentUser().getID()); - FileUtils.copyInputStreamToFile(optionalFileStream.get(), workFile); - workDir = new File(itemImportService.getTempWorkDir() + File.separator + TEMP_DIR - + File.separator + context.getCurrentUser().getID()); - sourcedir = itemImportService.unzip(workFile, workDir.getAbsolutePath()); - } else { - throw new IllegalArgumentException( - "Error reading file, the file couldn't be found for filename: " + zipfilename); + } finally { + optionalFileStream.ifPresent(IOUtils::closeQuietly); + validationFileStream.ifPresent(IOUtils::closeQuietly); } } } diff --git a/dspace-api/src/main/java/org/dspace/app/mediafilter/BrandedPreviewJPEGFilter.java b/dspace-api/src/main/java/org/dspace/app/mediafilter/BrandedPreviewJPEGFilter.java index 7b082c6c21a..483e4f5f6ea 100644 --- a/dspace-api/src/main/java/org/dspace/app/mediafilter/BrandedPreviewJPEGFilter.java +++ b/dspace-api/src/main/java/org/dspace/app/mediafilter/BrandedPreviewJPEGFilter.java @@ -7,9 +7,7 @@ */ package org.dspace.app.mediafilter; -import java.awt.image.BufferedImage; import java.io.InputStream; -import javax.imageio.ImageIO; import org.dspace.content.Item; import org.dspace.services.ConfigurationService; @@ -63,27 +61,20 @@ public String getDescription() { @Override public InputStream getDestinationStream(Item currentItem, InputStream source, boolean verbose) throws Exception { - // read in bitstream's image - BufferedImage buf = ImageIO.read(source); - // get config params ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); - float xmax = (float) configurationService - .getIntProperty("webui.preview.maxwidth"); - float ymax = (float) configurationService - .getIntProperty("webui.preview.maxheight"); - boolean blurring = (boolean) configurationService - .getBooleanProperty("webui.preview.blurring"); - boolean hqscaling = (boolean) configurationService - .getBooleanProperty("webui.preview.hqscaling"); + int xmax = configurationService.getIntProperty("webui.preview.maxwidth"); + int ymax = configurationService.getIntProperty("webui.preview.maxheight"); + boolean blurring = configurationService.getBooleanProperty("webui.preview.blurring"); + boolean hqscaling = configurationService.getBooleanProperty("webui.preview.hqscaling"); int brandHeight = configurationService.getIntProperty("webui.preview.brand.height"); String brandFont = configurationService.getProperty("webui.preview.brand.font"); int brandFontPoint = configurationService.getIntProperty("webui.preview.brand.fontpoint"); JPEGFilter jpegFilter = new JPEGFilter(); - return jpegFilter - .getThumbDim(currentItem, buf, verbose, xmax, ymax, blurring, hqscaling, brandHeight, brandFontPoint, - brandFont); + return jpegFilter.getThumb( + currentItem, source, verbose, xmax, ymax, blurring, hqscaling, brandHeight, brandFontPoint, brandFont + ); } } diff --git a/dspace-api/src/main/java/org/dspace/app/mediafilter/ImageMagickThumbnailFilter.java b/dspace-api/src/main/java/org/dspace/app/mediafilter/ImageMagickThumbnailFilter.java index 7543410a796..28bfc72dc11 100644 --- a/dspace-api/src/main/java/org/dspace/app/mediafilter/ImageMagickThumbnailFilter.java +++ b/dspace-api/src/main/java/org/dspace/app/mediafilter/ImageMagickThumbnailFilter.java @@ -105,7 +105,7 @@ public File getThumbnailFile(File f, boolean verbose) ConvertCmd cmd = new ConvertCmd(); IMOperation op = new IMOperation(); op.autoOrient(); - op.addImage(f.getAbsolutePath()); + op.addImage(f.getAbsolutePath() + "[0]"); op.thumbnail(configurationService.getIntProperty("thumbnail.maxwidth", DEFAULT_WIDTH), configurationService.getIntProperty("thumbnail.maxheight", DEFAULT_HEIGHT)); op.addImage(f2.getAbsolutePath()); diff --git a/dspace-api/src/main/java/org/dspace/app/mediafilter/JPEGFilter.java b/dspace-api/src/main/java/org/dspace/app/mediafilter/JPEGFilter.java index 502f71eb5ca..2ccc2afbb2d 100644 --- a/dspace-api/src/main/java/org/dspace/app/mediafilter/JPEGFilter.java +++ b/dspace-api/src/main/java/org/dspace/app/mediafilter/JPEGFilter.java @@ -8,19 +8,32 @@ package org.dspace.app.mediafilter; import java.awt.Color; +import java.awt.Dimension; import java.awt.Font; import java.awt.Graphics2D; import java.awt.RenderingHints; import java.awt.Transparency; +import java.awt.geom.AffineTransform; import java.awt.image.BufferedImage; import java.awt.image.BufferedImageOp; import java.awt.image.ConvolveOp; import java.awt.image.Kernel; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; import java.io.InputStream; import javax.imageio.ImageIO; +import com.drew.imaging.ImageMetadataReader; +import com.drew.imaging.ImageProcessingException; +import com.drew.metadata.Metadata; +import com.drew.metadata.MetadataException; +import com.drew.metadata.exif.ExifIFD0Directory; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.dspace.content.Item; import org.dspace.services.ConfigurationService; import org.dspace.services.factory.DSpaceServicesFactory; @@ -33,6 +46,8 @@ * @author Jason Sherman jsherman@usao.edu */ public class JPEGFilter extends MediaFilter implements SelfRegisterInputFormats { + private static final Logger log = LogManager.getLogger(JPEGFilter.class); + @Override public String getFilteredName(String oldFilename) { return oldFilename + ".jpg"; @@ -62,6 +77,115 @@ public String getDescription() { return "Generated Thumbnail"; } + /** + * Gets the rotation angle from image's metadata using ImageReader. + * This method consumes the InputStream, so you need to be careful to don't reuse the same InputStream after + * computing the rotation angle. + * + * @param buf InputStream of the image file + * @return Rotation angle in degrees (0, 90, 180, or 270) + */ + public static int getImageRotationUsingImageReader(InputStream buf) { + try { + Metadata metadata = ImageMetadataReader.readMetadata(buf); + ExifIFD0Directory directory = metadata.getFirstDirectoryOfType(ExifIFD0Directory.class); + if (directory != null && directory.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) { + return convertRotationToDegrees(directory.getInt(ExifIFD0Directory.TAG_ORIENTATION)); + } + } catch (MetadataException | ImageProcessingException | IOException e) { + log.error("Error reading image metadata", e); + } + return 0; + } + + public static int convertRotationToDegrees(int valueNode) { + // Common orientation values: + // 1 = Normal (0°) + // 6 = Rotated 90° CW + // 3 = Rotated 180° + // 8 = Rotated 270° CW + switch (valueNode) { + case 6: + return 90; + case 3: + return 180; + case 8: + return 270; + default: + return 0; + } + } + + /** + * Rotates an image by the specified angle + * + * @param image The original image + * @param angle The rotation angle in degrees + * @return Rotated image + */ + public static BufferedImage rotateImage(BufferedImage image, int angle) { + if (angle == 0) { + return image; + } + + double radians = Math.toRadians(angle); + double sin = Math.abs(Math.sin(radians)); + double cos = Math.abs(Math.cos(radians)); + + int newWidth = (int) Math.round(image.getWidth() * cos + image.getHeight() * sin); + int newHeight = (int) Math.round(image.getWidth() * sin + image.getHeight() * cos); + + BufferedImage rotated = new BufferedImage(newWidth, newHeight, image.getType()); + Graphics2D g2d = rotated.createGraphics(); + AffineTransform at = new AffineTransform(); + + at.translate(newWidth / 2, newHeight / 2); + at.rotate(radians); + at.translate(-image.getWidth() / 2, -image.getHeight() / 2); + + g2d.setTransform(at); + g2d.drawImage(image, 0, 0, null); + g2d.dispose(); + + return rotated; + } + + /** + * Calculates scaled dimension while maintaining aspect ratio + * + * @param imgSize Original image dimensions + * @param boundary Maximum allowed dimensions + * @return New dimensions that fit within boundary while preserving aspect ratio + */ + private Dimension getScaledDimension(Dimension imgSize, Dimension boundary) { + + int originalWidth = imgSize.width; + int originalHeight = imgSize.height; + int boundWidth = boundary.width; + int boundHeight = boundary.height; + int newWidth = originalWidth; + int newHeight = originalHeight; + + + // First check if we need to scale width + if (originalWidth > boundWidth) { + // Scale width to fit + newWidth = boundWidth; + // Scale height to maintain aspect ratio + newHeight = (newWidth * originalHeight) / originalWidth; + } + + // Then check if we need to scale even with the new height + if (newHeight > boundHeight) { + // Scale height to fit instead + newHeight = boundHeight; + newWidth = (newHeight * originalWidth) / originalHeight; + } + + return new Dimension(newWidth, newHeight); + } + + /** * @param currentItem item * @param source source input stream @@ -72,10 +196,65 @@ public String getDescription() { @Override public InputStream getDestinationStream(Item currentItem, InputStream source, boolean verbose) throws Exception { - // read in bitstream's image - BufferedImage buf = ImageIO.read(source); + return getThumb(currentItem, source, verbose); + } - return getThumb(currentItem, buf, verbose); + public InputStream getThumb(Item currentItem, InputStream source, boolean verbose) + throws Exception { + // get config params + final ConfigurationService configurationService + = DSpaceServicesFactory.getInstance().getConfigurationService(); + int xmax = configurationService + .getIntProperty("thumbnail.maxwidth"); + int ymax = configurationService + .getIntProperty("thumbnail.maxheight"); + boolean blurring = (boolean) configurationService + .getBooleanProperty("thumbnail.blurring"); + boolean hqscaling = (boolean) configurationService + .getBooleanProperty("thumbnail.hqscaling"); + + return getThumb(currentItem, source, verbose, xmax, ymax, blurring, hqscaling, 0, 0, null); + } + + protected InputStream getThumb( + Item currentItem, + InputStream source, + boolean verbose, + int xmax, + int ymax, + boolean blurring, + boolean hqscaling, + int brandHeight, + int brandFontPoint, + String brandFont + ) throws Exception { + + File tempFile = File.createTempFile("temp", ".tmp"); + tempFile.deleteOnExit(); + + // Write to temp file + try (FileOutputStream fos = new FileOutputStream(tempFile)) { + byte[] buffer = new byte[4096]; + int len; + while ((len = source.read(buffer)) != -1) { + fos.write(buffer, 0, len); + } + } + + int rotation = 0; + try (FileInputStream fis = new FileInputStream(tempFile)) { + rotation = getImageRotationUsingImageReader(fis); + } + + try (FileInputStream fis = new FileInputStream(tempFile)) { + // read in bitstream's image + BufferedImage buf = ImageIO.read(fis); + + return getThumbDim( + currentItem, buf, verbose, xmax, ymax, blurring, hqscaling, brandHeight, brandFontPoint, rotation, + brandFont + ); + } } public InputStream getThumb(Item currentItem, BufferedImage buf, boolean verbose) @@ -83,25 +262,28 @@ public InputStream getThumb(Item currentItem, BufferedImage buf, boolean verbose // get config params final ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); - float xmax = (float) configurationService + int xmax = configurationService .getIntProperty("thumbnail.maxwidth"); - float ymax = (float) configurationService + int ymax = configurationService .getIntProperty("thumbnail.maxheight"); boolean blurring = (boolean) configurationService .getBooleanProperty("thumbnail.blurring"); boolean hqscaling = (boolean) configurationService .getBooleanProperty("thumbnail.hqscaling"); - return getThumbDim(currentItem, buf, verbose, xmax, ymax, blurring, hqscaling, 0, 0, null); + return getThumbDim(currentItem, buf, verbose, xmax, ymax, blurring, hqscaling, 0, 0, 0, null); } - public InputStream getThumbDim(Item currentItem, BufferedImage buf, boolean verbose, float xmax, float ymax, + public InputStream getThumbDim(Item currentItem, BufferedImage buf, boolean verbose, int xmax, int ymax, boolean blurring, boolean hqscaling, int brandHeight, int brandFontPoint, - String brandFont) + int rotation, String brandFont) throws Exception { - // now get the image dimensions - float xsize = (float) buf.getWidth(null); - float ysize = (float) buf.getHeight(null); + + // Rotate the image if needed + BufferedImage correctedImage = rotateImage(buf, rotation); + + int xsize = correctedImage.getWidth(); + int ysize = correctedImage.getHeight(); // if verbose flag is set, print out dimensions // to STDOUT @@ -109,86 +291,63 @@ public InputStream getThumbDim(Item currentItem, BufferedImage buf, boolean verb System.out.println("original size: " + xsize + "," + ysize); } - // scale by x first if needed - if (xsize > xmax) { - // calculate scaling factor so that xsize * scale = new size (max) - float scale_factor = xmax / xsize; + // Calculate new dimensions while maintaining aspect ratio + Dimension newDimension = getScaledDimension( + new Dimension(xsize, ysize), + new Dimension(xmax, ymax) + ); - // if verbose flag is set, print out extracted text - // to STDOUT - if (verbose) { - System.out.println("x scale factor: " + scale_factor); - } - - // now reduce x size - // and y size - xsize = xsize * scale_factor; - ysize = ysize * scale_factor; - - // if verbose flag is set, print out extracted text - // to STDOUT - if (verbose) { - System.out.println("size after fitting to maximum width: " + xsize + "," + ysize); - } - } - - // scale by y if needed - if (ysize > ymax) { - float scale_factor = ymax / ysize; - - // now reduce x size - // and y size - xsize = xsize * scale_factor; - ysize = ysize * scale_factor; - } // if verbose flag is set, print details to STDOUT if (verbose) { - System.out.println("size after fitting to maximum height: " + xsize + ", " - + ysize); + System.out.println("size after fitting to maximum height: " + newDimension.width + ", " + + newDimension.height); } + xsize = newDimension.width; + ysize = newDimension.height; + // create an image buffer for the thumbnail with the new xsize, ysize - BufferedImage thumbnail = new BufferedImage((int) xsize, (int) ysize, - BufferedImage.TYPE_INT_RGB); + BufferedImage thumbnail = new BufferedImage(xsize, ysize, BufferedImage.TYPE_INT_RGB); // Use blurring if selected in config. // a little blur before scaling does wonders for keeping moire in check. if (blurring) { // send the buffered image off to get blurred. - buf = getBlurredInstance((BufferedImage) buf); + correctedImage = getBlurredInstance(correctedImage); } // Use high quality scaling method if selected in config. // this has a definite performance penalty. if (hqscaling) { // send the buffered image off to get an HQ downscale. - buf = getScaledInstance((BufferedImage) buf, (int) xsize, (int) ysize, - (Object) RenderingHints.VALUE_INTERPOLATION_BICUBIC, (boolean) true); + correctedImage = getScaledInstance(correctedImage, xsize, ysize, + RenderingHints.VALUE_INTERPOLATION_BICUBIC, true); } // now render the image into the thumbnail buffer Graphics2D g2d = thumbnail.createGraphics(); - g2d.drawImage(buf, 0, 0, (int) xsize, (int) ysize, null); + g2d.drawImage(correctedImage, 0, 0, xsize, ysize, null); if (brandHeight != 0) { ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); - Brand brand = new Brand((int) xsize, brandHeight, new Font(brandFont, Font.PLAIN, brandFontPoint), 5); + Brand brand = new Brand(xsize, brandHeight, new Font(brandFont, Font.PLAIN, brandFontPoint), 5); BufferedImage brandImage = brand.create(configurationService.getProperty("webui.preview.brand"), configurationService.getProperty("webui.preview.brand.abbrev"), currentItem == null ? "" : "hdl:" + currentItem.getHandle()); - g2d.drawImage(brandImage, (int) 0, (int) ysize, (int) xsize, (int) 20, null); + g2d.drawImage(brandImage, 0, ysize, xsize, 20, null); } - // now create an input stream for the thumbnail buffer and return it - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - - ImageIO.write(thumbnail, "jpeg", baos); - // now get the array - ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); + ByteArrayInputStream bais; + // now create an input stream for the thumbnail buffer and return it + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + ImageIO.write(thumbnail, "jpeg", baos); + // now get the array + bais = new ByteArrayInputStream(baos.toByteArray()); + } return bais; // hope this gets written out before its garbage collected! } diff --git a/dspace-api/src/main/java/org/dspace/app/mediafilter/PDFBoxThumbnail.java b/dspace-api/src/main/java/org/dspace/app/mediafilter/PDFBoxThumbnail.java index 94c463b2808..eb23e9daa08 100644 --- a/dspace-api/src/main/java/org/dspace/app/mediafilter/PDFBoxThumbnail.java +++ b/dspace-api/src/main/java/org/dspace/app/mediafilter/PDFBoxThumbnail.java @@ -83,6 +83,7 @@ public InputStream getDestinationStream(Item currentItem, InputStream source, bo // Generate thumbnail derivative and return as IO stream. JPEGFilter jpegFilter = new JPEGFilter(); + return jpegFilter.getThumb(currentItem, buf, verbose); } } diff --git a/dspace-api/src/main/java/org/dspace/app/requestitem/RequestItemServiceImpl.java b/dspace-api/src/main/java/org/dspace/app/requestitem/RequestItemServiceImpl.java index b915cfedd34..d6d0225655e 100644 --- a/dspace-api/src/main/java/org/dspace/app/requestitem/RequestItemServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/app/requestitem/RequestItemServiceImpl.java @@ -11,6 +11,7 @@ import java.util.Date; import java.util.Iterator; import java.util.List; +import java.util.UUID; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -96,6 +97,11 @@ public Iterator findByItem(Context context, Item item) throws SQLEx return requestItemDAO.findByItem(context, item); } + @Override + public Iterator findByBitstreamId(Context context, UUID bitstreamId) throws SQLException { + return requestItemDAO.findByBitstreamId(context, bitstreamId); + } + @Override public void update(Context context, RequestItem requestItem) { try { diff --git a/dspace-api/src/main/java/org/dspace/app/requestitem/dao/RequestItemDAO.java b/dspace-api/src/main/java/org/dspace/app/requestitem/dao/RequestItemDAO.java index b36ae58e0ca..9c6954fe6be 100644 --- a/dspace-api/src/main/java/org/dspace/app/requestitem/dao/RequestItemDAO.java +++ b/dspace-api/src/main/java/org/dspace/app/requestitem/dao/RequestItemDAO.java @@ -9,6 +9,7 @@ import java.sql.SQLException; import java.util.Iterator; +import java.util.UUID; import org.dspace.app.requestitem.RequestItem; import org.dspace.content.Item; @@ -26,7 +27,7 @@ */ public interface RequestItemDAO extends GenericDAO { /** - * Fetch a request named by its unique token (passed in emails). + * Fetch a request named by its unique approval token (passed in emails). * * @param context the current DSpace context. * @param token uniquely identifies the request. @@ -36,4 +37,17 @@ public interface RequestItemDAO extends GenericDAO { public RequestItem findByToken(Context context, String token) throws SQLException; public Iterator findByItem(Context context, Item item) throws SQLException; + + /** + * Retrieve all requests (as iterator) for a given bitstream UUID + * A UUID parameter is used here rather than Bitstream object, to make it usable + * in situations even when a bitstream object no longer exists, but orphaned + * entries need to be found by their (previous) bitstream UUID. + * + * @param context current DSpace context + * @param bitstreamId the bitstream UUID to search for + * @return the matching requests (or empty iterator) + */ + public Iterator findByBitstreamId(Context context, UUID bitstreamId) throws SQLException; + } diff --git a/dspace-api/src/main/java/org/dspace/app/requestitem/dao/impl/RequestItemDAOImpl.java b/dspace-api/src/main/java/org/dspace/app/requestitem/dao/impl/RequestItemDAOImpl.java index 008174ded88..b1ef405f5e1 100644 --- a/dspace-api/src/main/java/org/dspace/app/requestitem/dao/impl/RequestItemDAOImpl.java +++ b/dspace-api/src/main/java/org/dspace/app/requestitem/dao/impl/RequestItemDAOImpl.java @@ -9,6 +9,7 @@ import java.sql.SQLException; import java.util.Iterator; +import java.util.UUID; import javax.persistence.Query; import javax.persistence.criteria.CriteriaBuilder; import javax.persistence.criteria.CriteriaQuery; @@ -42,10 +43,22 @@ public RequestItem findByToken(Context context, String token) throws SQLExceptio criteriaQuery.where(criteriaBuilder.equal(requestItemRoot.get(RequestItem_.token), token)); return uniqueResult(context, criteriaQuery, false, RequestItem.class); } + @Override public Iterator findByItem(Context context, Item item) throws SQLException { - Query query = createQuery(context, "FROM RequestItem WHERE item_id= :uuid"); - query.setParameter("uuid", item.getID()); + CriteriaBuilder criteriaBuilder = getCriteriaBuilder(context); + CriteriaQuery criteriaQuery = getCriteriaQuery(criteriaBuilder, RequestItem.class); + Root requestItemRoot = criteriaQuery.from(RequestItem.class); + criteriaQuery.select(requestItemRoot); + criteriaQuery.where(criteriaBuilder.equal(requestItemRoot.get(RequestItem_.item), item)); + Query query = createQuery(context, criteriaQuery); + return iterate(query); + } + + @Override + public Iterator findByBitstreamId(Context context, UUID bitstreamId) throws SQLException { + Query query = createQuery(context, "FROM RequestItem WHERE bitstream.id = :bitstreamId"); + query.setParameter("bitstreamId", bitstreamId); return iterate(query); } } diff --git a/dspace-api/src/main/java/org/dspace/app/requestitem/service/RequestItemService.java b/dspace-api/src/main/java/org/dspace/app/requestitem/service/RequestItemService.java index efac3b18bc7..5c27ba9acb5 100644 --- a/dspace-api/src/main/java/org/dspace/app/requestitem/service/RequestItemService.java +++ b/dspace-api/src/main/java/org/dspace/app/requestitem/service/RequestItemService.java @@ -10,6 +10,7 @@ import java.sql.SQLException; import java.util.Iterator; import java.util.List; +import java.util.UUID; import org.dspace.app.requestitem.RequestItem; import org.dspace.content.Bitstream; @@ -40,7 +41,7 @@ public interface RequestItemService { * @return the token of the request item * @throws SQLException if database error */ - public String createRequest(Context context, Bitstream bitstream, Item item, + String createRequest(Context context, Bitstream bitstream, Item item, boolean allFiles, String reqEmail, String reqName, String reqMessage) throws SQLException; @@ -49,35 +50,51 @@ public String createRequest(Context context, Bitstream bitstream, Item item, * * @param context current DSpace session. * @return all item requests. - * @throws java.sql.SQLException passed through. + * @throws SQLException passed through. */ - public List findAll(Context context) + List findAll(Context context) throws SQLException; /** - * Retrieve a request by its token. + * Retrieve a request by its approver token. * * @param context current DSpace session. - * @param token the token identifying the request. + * @param token the token identifying the request to be approved. * @return the matching request, or null if not found. */ - public RequestItem findByToken(Context context, String token); + RequestItem findByToken(Context context, String token); /** - * Retrieve a request based on the item. + * Retrieve all requests (as iterator) for a given item * @param context current DSpace session. * @param item the item to find requests for. - * @return the matching requests, or null if not found. + * @return the matching requests (or empty iterator) */ - public Iterator findByItem(Context context, Item item) throws SQLException; + Iterator findByItem(Context context, Item item) throws SQLException; + /** - * Save updates to the record. Only accept_request, and decision_date are set-able. + * Retrieve all requests (as iterator) for a given bitstream UUID + * A UUID parameter is used here rather than Bitstream object, to make it usable + * in situations even when a bitstream object no longer exists, but orphaned + * entries need to be found by their (previous) bitstream UUID. + * + * @param context current DSpace context + * @param bitstreamId the bitstream UUID to search for + * @return the matching requests (or empty iterator) + */ + Iterator findByBitstreamId(Context context, UUID bitstreamId) throws SQLException; + + /** + * Save updates to the record. Only accept_request, decision_date, access_period are settable. + * + * Note: the "is settable" rules mentioned here are enforced in RequestItemRest with annotations meaning that + * these JSON properties are considered READ-ONLY by the core DSpaceRestRepository methods * * @param context The relevant DSpace Context. * @param requestItem requested item */ - public void update(Context context, RequestItem requestItem); + void update(Context context, RequestItem requestItem); /** * Remove the record from the database. @@ -85,7 +102,7 @@ public List findAll(Context context) * @param context current DSpace context. * @param request record to be removed. */ - public void delete(Context context, RequestItem request); + void delete(Context context, RequestItem request); /** * Is there at least one valid READ resource policy for this object? @@ -94,6 +111,6 @@ public List findAll(Context context) * @return true if a READ policy applies. * @throws SQLException passed through. */ - public boolean isRestricted(Context context, DSpaceObject o) + boolean isRestricted(Context context, DSpaceObject o) throws SQLException; } diff --git a/dspace-api/src/main/java/org/dspace/app/util/DSpaceObjectUtilsImpl.java b/dspace-api/src/main/java/org/dspace/app/util/DSpaceObjectUtilsImpl.java index e3f2b0ea5fa..33621abd529 100644 --- a/dspace-api/src/main/java/org/dspace/app/util/DSpaceObjectUtilsImpl.java +++ b/dspace-api/src/main/java/org/dspace/app/util/DSpaceObjectUtilsImpl.java @@ -15,12 +15,15 @@ import org.dspace.content.factory.ContentServiceFactory; import org.dspace.content.service.DSpaceObjectService; import org.dspace.core.Context; +import org.dspace.handle.service.HandleService; import org.springframework.beans.factory.annotation.Autowired; public class DSpaceObjectUtilsImpl implements DSpaceObjectUtils { @Autowired private ContentServiceFactory contentServiceFactory; + @Autowired + private HandleService handleService; /** * Retrieve a DSpaceObject from its uuid. As this method need to iterate over all the different services that @@ -44,4 +47,32 @@ public DSpaceObject findDSpaceObject(Context context, UUID uuid) throws SQLExcep } return null; } + + /** + * Retrieve a DSpaceObject from its uuid or handle. As this method need to iterate over all the different services + * that support concrete class of DSpaceObject it has poor performance. Please consider the use of the direct + * service (ItemService, CommunityService, etc.) if you know in advance the type of DSpaceObject that you are + * looking for + * + * @param context DSpace context + * @param id the uuid or handle to lookup + * @return the DSpaceObject if any with the supplied uuid or handle + * @throws SQLException + */ + public DSpaceObject findDSpaceObject(Context context, String id) throws SQLException { + DSpaceObject dso = handleService.resolveToObject(context, id); + // if the id did not resolve to a handle, check if it is a uuid + if (dso == null) { + UUID uuid = null; + try { + uuid = UUID.fromString(id); + } catch (IllegalArgumentException iae) { + // nothing to do here. We check later fo empty uuids anyway + } + if (uuid != null) { + dso = findDSpaceObject(context, uuid); + } + } + return dso; + } } diff --git a/dspace-api/src/main/java/org/dspace/app/util/MetadataExposureServiceImpl.java b/dspace-api/src/main/java/org/dspace/app/util/MetadataExposureServiceImpl.java index 55deff2853d..386b6e99886 100644 --- a/dspace-api/src/main/java/org/dspace/app/util/MetadataExposureServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/app/util/MetadataExposureServiceImpl.java @@ -67,7 +67,7 @@ public class MetadataExposureServiceImpl implements MetadataExposureService { protected Map> hiddenElementSets = null; protected Map>> hiddenElementMaps = null; - protected final String CONFIG_PREFIX = "metadata.hide."; + public static final String CONFIG_PREFIX = "metadata.hide."; /** * You can define hidden metadata could be seen by the submitter. diff --git a/dspace-api/src/main/java/org/dspace/app/util/service/DSpaceObjectUtils.java b/dspace-api/src/main/java/org/dspace/app/util/service/DSpaceObjectUtils.java index e6a97004ef6..8088a4ca4de 100644 --- a/dspace-api/src/main/java/org/dspace/app/util/service/DSpaceObjectUtils.java +++ b/dspace-api/src/main/java/org/dspace/app/util/service/DSpaceObjectUtils.java @@ -30,4 +30,17 @@ public interface DSpaceObjectUtils { * @throws SQLException */ public DSpaceObject findDSpaceObject(Context context, UUID uuid) throws SQLException; + + /** + * Retrieve a DSpaceObject from its uuid or handle. As this method need to iterate over all the different services + * that support concrete class of DSpaceObject it has poor performance. Please consider the use of the direct + * service (ItemService, CommunityService, etc.) if you know in advance the type of DSpaceObject that you are + * looking for + * + * @param context DSpace context + * @param id the uuid or handle to lookup + * @return the DSpaceObject if any with the supplied uuid or handle + * @throws SQLException + */ + public DSpaceObject findDSpaceObject(Context context, String id) throws SQLException; } diff --git a/dspace-api/src/main/java/org/dspace/authenticate/AuthenticationMethod.java b/dspace-api/src/main/java/org/dspace/authenticate/AuthenticationMethod.java index 500ee04a979..7c8793a6c21 100644 --- a/dspace-api/src/main/java/org/dspace/authenticate/AuthenticationMethod.java +++ b/dspace-api/src/main/java/org/dspace/authenticate/AuthenticationMethod.java @@ -54,7 +54,7 @@ public interface AuthenticationMethod { public static final int BAD_CREDENTIALS = 2; /** - * Not allowed to login this way without X.509 certificate. + * Not allowed to login this way without a certificate. */ public static final int CERT_REQUIRED = 3; @@ -124,8 +124,8 @@ public boolean allowSetPassword(Context context, * Predicate, is this an implicit authentication method. * An implicit method gets credentials from the environment (such as * an HTTP request or even Java system properties) rather than the - * explicit username and password. For example, a method that reads - * the X.509 certificates in an HTTPS request is implicit. + * explicit username and password. For example, a method that provides + * IP-based authentication is implicit. * * @return true if this method uses implicit authentication. */ @@ -166,7 +166,7 @@ public List getSpecialGroups(Context context, HttpServletRequest request) * otherwise */ public default boolean areSpecialGroupsApplicable(Context context, HttpServletRequest request) { - return getName().equals(context.getAuthenticationMethod()); + return getName().equals(context.getAuthenticationMethod()) || isUsed(context, request); } /** @@ -188,7 +188,7 @@ public default boolean areSpecialGroupsApplicable(Context context, HttpServletRe *

Meaning: *
SUCCESS - authenticated OK. *
BAD_CREDENTIALS - user exists, but credentials (e.g. passwd) don't match - *
CERT_REQUIRED - not allowed to login this way without X.509 cert. + *
CERT_REQUIRED - not allowed to login this way without a cert. *
NO_SUCH_USER - user not found using this method. *
BAD_ARGS - user/pw not appropriate for this method * @throws SQLException if database error diff --git a/dspace-api/src/main/java/org/dspace/authenticate/AuthenticationServiceImpl.java b/dspace-api/src/main/java/org/dspace/authenticate/AuthenticationServiceImpl.java index 1d67da37ecb..4ad7fbd8c34 100644 --- a/dspace-api/src/main/java/org/dspace/authenticate/AuthenticationServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/authenticate/AuthenticationServiceImpl.java @@ -38,11 +38,11 @@ * Configuration
* The stack of authentication methods is defined by one property in the DSpace configuration: *

- *   plugin.sequence.org.dspace.eperson.AuthenticationMethod = a list of method class names
+ *   plugin.sequence.org.dspace.authenticate.AuthenticationMethod = a list of method class names
  *     e.g.
- *   plugin.sequence.org.dspace.eperson.AuthenticationMethod = \
- *       org.dspace.eperson.X509Authentication, \
- *       org.dspace.eperson.PasswordAuthentication
+ *   plugin.sequence.org.dspace.authenticate.AuthenticationMethod = \
+ *       org.dspace.authenticate.IPAuthentication, \
+ *       org.dspace.authenticate.PasswordAuthentication
  * 
*

* The "stack" is always traversed in order, with the methods @@ -111,6 +111,7 @@ protected int authenticateInternal(Context context, } if (ret == AuthenticationMethod.SUCCESS) { updateLastActiveDate(context); + context.setAuthenticationMethod(aMethodStack.getName()); return ret; } if (ret < bestRet) { 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 da6a7092481..888ae2530e5 100644 --- a/dspace-api/src/main/java/org/dspace/authenticate/LDAPAuthentication.java +++ b/dspace-api/src/main/java/org/dspace/authenticate/LDAPAuthentication.java @@ -203,7 +203,7 @@ public List getSpecialGroups(Context context, HttpServletRequest request) *

Meaning: *
SUCCESS - authenticated OK. *
BAD_CREDENTIALS - user exists, but credentials (e.g. passwd) don't match - *
CERT_REQUIRED - not allowed to login this way without X.509 cert. + *
CERT_REQUIRED - not allowed to login this way without a cert. *
NO_SUCH_USER - user not found using this method. *
BAD_ARGS - user/pw not appropriate for this method */ @@ -322,7 +322,7 @@ public int authenticate(Context context, log.info(LogHelper.getHeader(context, "type=ldap-login", "type=ldap_but_already_email")); context.turnOffAuthorisationSystem(); - setEpersonAttributes(context, eperson, ldap, Optional.of(netid)); + setEpersonAttributes(context, eperson, ldap, Optional.of(netid), email); ePersonService.update(context, eperson); context.dispatchEvents(); context.restoreAuthSystemState(); @@ -339,7 +339,7 @@ public int authenticate(Context context, try { context.turnOffAuthorisationSystem(); eperson = ePersonService.create(context); - setEpersonAttributes(context, eperson, ldap, Optional.of(netid)); + setEpersonAttributes(context, eperson, ldap, Optional.of(netid), email); eperson.setCanLogIn(true); authenticationService.initEPerson(context, request, eperson); ePersonService.update(context, eperson); @@ -381,11 +381,24 @@ public int authenticate(Context context, * Update eperson's attributes */ private void setEpersonAttributes(Context context, EPerson eperson, SpeakerToLDAP ldap, Optional netid) - throws SQLException { + throws SQLException { + setEpersonAttributes(context, eperson, ldap, netid, null); + } + /** + * Update eperson's attributes + */ + private void setEpersonAttributes(Context context, EPerson eperson, SpeakerToLDAP ldap, Optional netid, + String email) + throws SQLException { + + // Set email address: first try LDAP email, then fallback to provided email parameter if (StringUtils.isNotEmpty(ldap.ldapEmail)) { eperson.setEmail(ldap.ldapEmail); + } else if (StringUtils.isNotEmpty(email)) { + eperson.setEmail(email); } + if (StringUtils.isNotEmpty(ldap.ldapGivenName)) { eperson.setFirstName(context, ldap.ldapGivenName); } diff --git a/dspace-api/src/main/java/org/dspace/authenticate/OrcidAuthenticationBean.java b/dspace-api/src/main/java/org/dspace/authenticate/OrcidAuthenticationBean.java index a11bbfc867b..b5c238f3344 100644 --- a/dspace-api/src/main/java/org/dspace/authenticate/OrcidAuthenticationBean.java +++ b/dspace-api/src/main/java/org/dspace/authenticate/OrcidAuthenticationBean.java @@ -120,7 +120,7 @@ public String loginPageURL(Context context, HttpServletRequest request, HttpServ @Override public boolean isUsed(Context context, HttpServletRequest request) { - return request.getAttribute(ORCID_AUTH_ATTRIBUTE) != null; + return request != null && request.getAttribute(ORCID_AUTH_ATTRIBUTE) != null; } @Override diff --git a/dspace-api/src/main/java/org/dspace/authenticate/PasswordAuthentication.java b/dspace-api/src/main/java/org/dspace/authenticate/PasswordAuthentication.java index 6d1ca862d30..69f0bd55f87 100644 --- a/dspace-api/src/main/java/org/dspace/authenticate/PasswordAuthentication.java +++ b/dspace-api/src/main/java/org/dspace/authenticate/PasswordAuthentication.java @@ -188,7 +188,7 @@ public List getSpecialGroups(Context context, HttpServletRequest request) *

Meaning: *
SUCCESS - authenticated OK. *
BAD_CREDENTIALS - user exists, but password doesn't match - *
CERT_REQUIRED - not allowed to login this way without X.509 cert. + *
CERT_REQUIRED - not allowed to login this way without a cert. *
NO_SUCH_USER - no EPerson with matching email address. *
BAD_ARGS - missing username, or user matched but cannot login. * @throws SQLException if database error @@ -213,7 +213,7 @@ public int authenticate(Context context, // cannot login this way return BAD_ARGS; } else if (eperson.getRequireCertificate()) { - // this user can only login with x.509 certificate + // this user can only login with a certificate log.warn(LogHelper.getHeader(context, "authenticate", "rejecting PasswordAuthentication because " + username + " requires " + "certificate.")); diff --git a/dspace-api/src/main/java/org/dspace/authenticate/ShibAuthentication.java b/dspace-api/src/main/java/org/dspace/authenticate/ShibAuthentication.java index d9d5338877e..7911993437e 100644 --- a/dspace-api/src/main/java/org/dspace/authenticate/ShibAuthentication.java +++ b/dspace-api/src/main/java/org/dspace/authenticate/ShibAuthentication.java @@ -166,7 +166,7 @@ public class ShibAuthentication implements AuthenticationMethod { * SUCCESS - authenticated OK.
* BAD_CREDENTIALS - user exists, but credentials (e.g. passwd) * don't match
- * CERT_REQUIRED - not allowed to login this way without X.509 cert. + * CERT_REQUIRED - not allowed to login this way without a cert. *
* NO_SUCH_USER - user not found using this method.
* BAD_ARGS - user/pw not appropriate for this method @@ -422,8 +422,7 @@ public boolean allowSetPassword(Context context, * Predicate, is this an implicit authentication method. An implicit method * gets credentials from the environment (such as an HTTP request or even * Java system properties) rather than the explicit username and password. - * For example, a method that reads the X.509 certificates in an HTTPS - * request is implicit. + * For example, a method that provides IP-based authentication is implicit. * * @return true if this method uses implicit authentication. */ @@ -899,7 +898,7 @@ protected void updateEPerson(Context context, HttpServletRequest request, EPerso String[] nameParts = MetadataFieldName.parse(field); ePersonService.setMetadataSingleValue(context, eperson, - nameParts[0], nameParts[1], nameParts[2], value, null); + nameParts[0], nameParts[1], nameParts[2], null, value); log.debug("Updated the eperson's '{}' metadata using header: '{}' = '{}'.", field, header, value); } @@ -945,7 +944,7 @@ protected int swordCompatibility(Context context, String username, String passwo " is not allowed to login."); return BAD_ARGS; } else if (eperson.getRequireCertificate()) { - // this user can only login with x.509 certificate + // this user can only login with a certificate log.error( "Shibboleth-based password authentication failed for user " + username + " because the eperson object" + " requires a certificate to authenticate.."); diff --git a/dspace-api/src/main/java/org/dspace/authenticate/X509Authentication.java b/dspace-api/src/main/java/org/dspace/authenticate/X509Authentication.java deleted file mode 100644 index 12dc5feda58..00000000000 --- a/dspace-api/src/main/java/org/dspace/authenticate/X509Authentication.java +++ /dev/null @@ -1,616 +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.authenticate; - -import java.io.BufferedInputStream; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.security.GeneralSecurityException; -import java.security.KeyStore; -import java.security.Principal; -import java.security.PublicKey; -import java.security.cert.Certificate; -import java.security.cert.CertificateException; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Enumeration; -import java.util.List; -import java.util.StringTokenizer; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import javax.servlet.http.HttpSession; - -import org.apache.commons.lang3.ArrayUtils; -import org.apache.logging.log4j.Logger; -import org.dspace.authenticate.factory.AuthenticateServiceFactory; -import org.dspace.authenticate.service.AuthenticationService; -import org.dspace.authorize.AuthorizeException; -import org.dspace.core.Context; -import org.dspace.core.LogHelper; -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; - -/** - * Implicit authentication method that gets credentials from the X.509 client - * certificate supplied by the HTTPS client when connecting to this server. The - * email address in that certificate is taken as the authenticated user name - * with no further checking, so be sure your HTTP server (e.g. Tomcat) is - * configured correctly to accept only client certificates it can validate. - *

- * See the AuthenticationMethod interface for more details. - *

- * Configuration: - * - *

- *   x509.keystore.path =
- * 
- * path to Java keystore file
- * 
- *   keystore.password =
- * 
- * password to access the keystore
- * 
- *   ca.cert =
- * 
- * path to certificate file for CA whose client certs to accept.
- * 
- *   autoregister =
- * 
- * "true" if E-Person is created automatically for unknown new users.
- * 
- *   groups =
- * 
- * comma-delimited list of special groups to add user to if authenticated.
- * 
- *   emaildomain =
- * 
- * email address domain (after the 'at' symbol) to match before allowing
- * membership in special groups.
- * 
- * 
- * - * Only one of the "keystore.path" or "ca.cert" - * options is required. If you supply a keystore, then all of the "trusted" - * certificates in the keystore represent CAs whose client certificates will be - * accepted. The ca.cert option only allows a single CA to be - * named. - *

- * You can configure both a keystore and a CA cert, and both will be - * used. - *

- * The autoregister configuration parameter determines what the - * canSelfRegister() method returns. It also allows an EPerson - * record to be created automatically when the presented certificate is - * acceptable but there is no corresponding EPerson. - * - * @author Larry Stone - * @version $Revision$ - */ -public class X509Authentication implements AuthenticationMethod { - - /** - * log4j category - */ - private static Logger log = org.apache.logging.log4j.LogManager.getLogger(X509Authentication.class); - - /** - * public key of CA to check client certs against. - */ - private static PublicKey caPublicKey = null; - - /** - * key store for CA certs if we use that - */ - private static KeyStore caCertKeyStore = null; - - private static String loginPageTitle = null; - - private static String loginPageURL = null; - - protected AuthenticationService authenticationService = AuthenticateServiceFactory.getInstance() - .getAuthenticationService(); - protected EPersonService ePersonService = EPersonServiceFactory.getInstance().getEPersonService(); - protected GroupService groupService = EPersonServiceFactory.getInstance().getGroupService(); - protected ConfigurationService configurationService = - DSpaceServicesFactory.getInstance().getConfigurationService(); - - private static final String X509_AUTHENTICATED = "x509.authenticated"; - - - /** - * Initialization: Set caPublicKey and/or keystore. This loads the - * information needed to check if a client cert presented is valid and - * acceptable. - */ - static { - ConfigurationService configurationService = - DSpaceServicesFactory.getInstance().getConfigurationService(); - /* - * allow identification of alternative entry points for certificate - * authentication when selected by the user rather than implicitly. - */ - loginPageTitle = configurationService - .getProperty("authentication-x509.chooser.title.key"); - loginPageURL = configurationService - .getProperty("authentication-x509.chooser.uri"); - - String keystorePath = configurationService - .getProperty("authentication-x509.keystore.path"); - String keystorePassword = configurationService - .getProperty("authentication-x509.keystore.password"); - String caCertPath = configurationService - .getProperty("authentication-x509.ca.cert"); - - // First look for keystore full of trusted certs. - if (keystorePath != null) { - FileInputStream fis = null; - if (keystorePassword == null) { - keystorePassword = ""; - } - try { - KeyStore ks = KeyStore.getInstance("JKS"); - fis = new FileInputStream(keystorePath); - ks.load(fis, keystorePassword.toCharArray()); - caCertKeyStore = ks; - } catch (IOException e) { - log - .error("X509Authentication: Failed to load CA keystore, file=" - + keystorePath + ", error=" + e.toString()); - } catch (GeneralSecurityException e) { - log - .error("X509Authentication: Failed to extract CA keystore, file=" - + keystorePath + ", error=" + e.toString()); - } finally { - if (fis != null) { - try { - fis.close(); - } catch (IOException ioe) { - // ignore - } - } - } - } - - // Second, try getting public key out of CA cert, if that's configured. - if (caCertPath != null) { - InputStream is = null; - FileInputStream fis = null; - try { - fis = new FileInputStream(caCertPath); - is = new BufferedInputStream(fis); - X509Certificate cert = (X509Certificate) CertificateFactory - .getInstance("X.509").generateCertificate(is); - if (cert != null) { - caPublicKey = cert.getPublicKey(); - } - } catch (IOException e) { - log.error("X509Authentication: Failed to load CA cert, file=" - + caCertPath + ", error=" + e.toString()); - } catch (CertificateException e) { - log - .error("X509Authentication: Failed to extract CA cert, file=" - + caCertPath + ", error=" + e.toString()); - } finally { - if (is != null) { - try { - is.close(); - } catch (IOException ioe) { - // ignore - } - } - - if (fis != null) { - try { - fis.close(); - } catch (IOException ioe) { - // ignore - } - } - } - } - } - - /** - * Return the email address from certificate, or null if an - * email address cannot be found in the certificate. - *

- * Note that the certificate parsing has only been tested with certificates - * granted by the MIT Certification Authority, and may not work elsewhere. - * - * @param certificate - - * An X509 certificate object - * @return - The email address found in certificate, or null if an email - * address cannot be found in the certificate. - */ - private static String getEmail(X509Certificate certificate) - throws SQLException { - Principal principal = certificate.getSubjectDN(); - - if (principal == null) { - return null; - } - - String dn = principal.getName(); - if (dn == null) { - return null; - } - - StringTokenizer tokenizer = new StringTokenizer(dn, ","); - String token = null; - while (tokenizer.hasMoreTokens()) { - int len = "emailaddress=".length(); - - token = (String) tokenizer.nextToken(); - - if (token.toLowerCase().startsWith("emailaddress=")) { - // Make sure the token actually contains something - if (token.length() <= len) { - return null; - } - - return token.substring(len).toLowerCase(); - } - } - - return null; - } - - /** - * Verify CERTIFICATE against KEY. Return true if and only if CERTIFICATE is - * valid and can be verified against KEY. - * - * @param context The current DSpace context - * @param certificate - - * An X509 certificate object - * @return - True if CERTIFICATE is valid and can be verified against KEY, - * false otherwise. - */ - private static boolean isValid(Context context, X509Certificate certificate) { - if (certificate == null) { - return false; - } - - // This checks that current time is within cert's validity window: - try { - certificate.checkValidity(); - } catch (CertificateException e) { - log.info(LogHelper.getHeader(context, "authentication", - "X.509 Certificate is EXPIRED or PREMATURE: " - + e.toString())); - return false; - } - - // Try CA public key, if available. - if (caPublicKey != null) { - try { - certificate.verify(caPublicKey); - return true; - } catch (GeneralSecurityException e) { - log.info(LogHelper.getHeader(context, "authentication", - "X.509 Certificate FAILED SIGNATURE check: " - + e.toString())); - } - } - - // Try it with keystore, if available. - if (caCertKeyStore != null) { - try { - Enumeration ke = caCertKeyStore.aliases(); - - while (ke.hasMoreElements()) { - String alias = (String) ke.nextElement(); - if (caCertKeyStore.isCertificateEntry(alias)) { - Certificate ca = caCertKeyStore.getCertificate(alias); - try { - certificate.verify(ca.getPublicKey()); - return true; - } catch (CertificateException ce) { - // ignore - } - } - } - log - .info(LogHelper - .getHeader(context, "authentication", - "Keystore method FAILED SIGNATURE check on client cert.")); - } catch (GeneralSecurityException e) { - log.info(LogHelper.getHeader(context, "authentication", - "X.509 Certificate FAILED SIGNATURE check: " - + e.toString())); - } - - } - return false; - } - - /** - * Predicate, can new user automatically create EPerson. Checks - * configuration value. You'll probably want this to be true to take - * advantage of a Web certificate infrastructure with many more users than - * are already known by DSpace. - * - * @throws SQLException if database error - */ - @Override - public boolean canSelfRegister(Context context, HttpServletRequest request, - String username) throws SQLException { - return configurationService - .getBooleanProperty("authentication-x509.autoregister"); - } - - /** - * Nothing extra to initialize. - * - * @throws SQLException if database error - */ - @Override - public void initEPerson(Context context, HttpServletRequest request, - EPerson eperson) throws SQLException { - } - - /** - * We don't use EPerson password so there is no reason to change it. - * - * @throws SQLException if database error - */ - @Override - public boolean allowSetPassword(Context context, - HttpServletRequest request, String username) throws SQLException { - return false; - } - - /** - * Returns true, this is an implicit method. - */ - @Override - public boolean isImplicit() { - return true; - } - - /** - * Returns a list of group names that the user should be added to upon - * successful authentication, configured in dspace.cfg. - * - * @return List of special groups configured for this authenticator - */ - private List getX509Groups() { - List groupNames = new ArrayList(); - - String[] groups = configurationService - .getArrayProperty("authentication-x509.groups"); - - if (ArrayUtils.isNotEmpty(groups)) { - for (String group : groups) { - groupNames.add(group.trim()); - } - } - - return groupNames; - } - - /** - * Checks for configured email domain required to grant special groups - * membership. If no email domain is configured to verify, special group - * membership is simply granted. - * - * @param request - - * The current request object - * @param email - - * The email address from the x509 certificate - */ - private void setSpecialGroupsFlag(HttpServletRequest request, String email) { - String emailDomain = null; - emailDomain = (String) request - .getAttribute("authentication.x509.emaildomain"); - - HttpSession session = request.getSession(true); - - if (null != emailDomain && !"".equals(emailDomain)) { - if (email.substring(email.length() - emailDomain.length()).equals( - emailDomain)) { - session.setAttribute("x509Auth", Boolean.TRUE); - } - } else { - // No configured email domain to verify. Just flag - // as authenticated so special groups are granted. - session.setAttribute("x509Auth", Boolean.TRUE); - } - } - - /** - * Return special groups configured in dspace.cfg for X509 certificate - * authentication. - * - * @param context context - * @param request object potentially containing the cert - * @return An int array of group IDs - * @throws SQLException if database error - */ - @Override - public List getSpecialGroups(Context context, HttpServletRequest request) - throws SQLException { - if (request == null) { - return Collections.EMPTY_LIST; - } - - Boolean authenticated = false; - HttpSession session = request.getSession(false); - authenticated = (Boolean) session.getAttribute("x509Auth"); - authenticated = (null == authenticated) ? false : authenticated; - - if (authenticated) { - List groupNames = getX509Groups(); - List groups = new ArrayList<>(); - - if (groupNames != null) { - for (String groupName : groupNames) { - if (groupName != null) { - Group group = groupService.findByName(context, groupName); - if (group != null) { - groups.add(group); - } else { - log.warn(LogHelper.getHeader(context, - "configuration_error", "unknown_group=" - + groupName)); - } - } - } - } - - return groups; - } - - return Collections.EMPTY_LIST; - } - - /** - * X509 certificate authentication. The client certificate is obtained from - * the ServletRequest object. - *

    - *
  • If the certificate is valid, and corresponds to an existing EPerson, - * and the user is allowed to login, return success.
  • - *
  • If the user is matched but is not allowed to login, it fails.
  • - *
  • If the certificate is valid, but there is no corresponding EPerson, - * the "authentication.x509.autoregister" configuration - * parameter is checked (via canSelfRegister()) - *
      - *
    • If it's true, a new EPerson record is created for the certificate, - * and the result is success.
    • - *
    • If it's false, return that the user was unknown.
    • - *
    - *
  • - *
- * - * @return One of: SUCCESS, BAD_CREDENTIALS, NO_SUCH_USER, BAD_ARGS - * @throws SQLException if database error - */ - @Override - public int authenticate(Context context, String username, String password, - String realm, HttpServletRequest request) throws SQLException { - // Obtain the certificate from the request, if any - X509Certificate[] certs = null; - if (request != null) { - certs = (X509Certificate[]) request - .getAttribute("javax.servlet.request.X509Certificate"); - } - - if ((certs == null) || (certs.length == 0)) { - return BAD_ARGS; - } else { - // We have a cert -- check it and get username from it. - try { - if (!isValid(context, certs[0])) { - log - .warn(LogHelper - .getHeader(context, "authenticate", - "type=x509certificate, status=BAD_CREDENTIALS (not valid)")); - return BAD_CREDENTIALS; - } - - // And it's valid - try and get an e-person - String email = getEmail(certs[0]); - EPerson eperson = null; - if (email != null) { - eperson = ePersonService.findByEmail(context, email); - } - if (eperson == null) { - // Cert is valid, but no record. - if (email != null - && canSelfRegister(context, request, null)) { - // Register the new user automatically - log.info(LogHelper.getHeader(context, "autoregister", - "from=x.509, email=" + email)); - - // TEMPORARILY turn off authorisation - context.turnOffAuthorisationSystem(); - eperson = ePersonService.create(context); - eperson.setEmail(email); - eperson.setCanLogIn(true); - authenticationService.initEPerson(context, request, - eperson); - ePersonService.update(context, eperson); - context.dispatchEvents(); - context.restoreAuthSystemState(); - context.setCurrentUser(eperson); - request.setAttribute(X509_AUTHENTICATED, true); - setSpecialGroupsFlag(request, email); - return SUCCESS; - } else { - // No auto-registration for valid certs - log - .warn(LogHelper - .getHeader(context, "authenticate", - "type=cert_but_no_record, cannot auto-register")); - return NO_SUCH_USER; - } - } else if (!eperson.canLogIn()) { // make sure this is a login account - log.warn(LogHelper.getHeader(context, "authenticate", - "type=x509certificate, email=" + email - + ", canLogIn=false, rejecting.")); - return BAD_ARGS; - } else { - log.info(LogHelper.getHeader(context, "login", - "type=x509certificate")); - context.setCurrentUser(eperson); - request.setAttribute(X509_AUTHENTICATED, true); - setSpecialGroupsFlag(request, email); - return SUCCESS; - } - } catch (AuthorizeException ce) { - log.warn(LogHelper.getHeader(context, "authorize_exception", - ""), ce); - } - - return BAD_ARGS; - } - } - - /** - * Returns URL of password-login servlet. - * - * @param context DSpace context, will be modified (EPerson set) upon success. - * @param request The HTTP request that started this operation, or null if not - * applicable. - * @param response The HTTP response from the servlet method. - * @return fully-qualified URL - */ - @Override - public String loginPageURL(Context context, HttpServletRequest request, - HttpServletResponse response) { - return loginPageURL; - } - - @Override - public String getName() { - return "x509"; - } - - @Override - public boolean isUsed(final Context context, final HttpServletRequest request) { - if (request != null && - context.getCurrentUser() != null && - request.getAttribute(X509_AUTHENTICATED) != null) { - return true; - } - return false; - } - - @Override - public boolean canChangePassword(Context context, EPerson ePerson, String currentPassword) { - return false; - } -} diff --git a/dspace-api/src/main/java/org/dspace/authenticate/service/AuthenticationService.java b/dspace-api/src/main/java/org/dspace/authenticate/service/AuthenticationService.java index e955302ec3d..105e30d9bdf 100644 --- a/dspace-api/src/main/java/org/dspace/authenticate/service/AuthenticationService.java +++ b/dspace-api/src/main/java/org/dspace/authenticate/service/AuthenticationService.java @@ -29,11 +29,11 @@ * Configuration
* The stack of authentication methods is defined by one property in the DSpace configuration: *
- *   plugin.sequence.org.dspace.eperson.AuthenticationMethod = a list of method class names
+ *   plugin.sequence.org.dspace.authenticate.AuthenticationMethod = a list of method class names
  *     e.g.
- *   plugin.sequence.org.dspace.eperson.AuthenticationMethod = \
- *       org.dspace.eperson.X509Authentication, \
- *       org.dspace.eperson.PasswordAuthentication
+ *   plugin.sequence.org.dspace.authenticate.AuthenticationMethod = \
+ *       org.dspace.authenticate.IPAuthentication, \
+ *       org.dspace.authenticate.PasswordAuthentication
  * 
*

* The "stack" is always traversed in order, with the methods @@ -64,7 +64,7 @@ public interface AuthenticationService { *

Meaning: *
SUCCESS - authenticated OK. *
BAD_CREDENTIALS - user exists, but credentials (e.g. password) don't match - *
CERT_REQUIRED - not allowed to login this way without X.509 cert. + *
CERT_REQUIRED - not allowed to login this way without a cert. *
NO_SUCH_USER - user not found using this method. *
BAD_ARGS - user/password not appropriate for this method */ @@ -91,7 +91,7 @@ public int authenticate(Context context, *

Meaning: *
SUCCESS - authenticated OK. *
BAD_CREDENTIALS - user exists, but credentials (e.g. password) don't match - *
CERT_REQUIRED - not allowed to login this way without X.509 cert. + *
CERT_REQUIRED - not allowed to login this way without a cert. *
NO_SUCH_USER - user not found using this method. *
BAD_ARGS - user/password not appropriate for this method */ diff --git a/dspace-api/src/main/java/org/dspace/authority/orcid/Orcidv3SolrAuthorityImpl.java b/dspace-api/src/main/java/org/dspace/authority/orcid/Orcidv3SolrAuthorityImpl.java index 494daa97734..312a00c146a 100644 --- a/dspace-api/src/main/java/org/dspace/authority/orcid/Orcidv3SolrAuthorityImpl.java +++ b/dspace-api/src/main/java/org/dspace/authority/orcid/Orcidv3SolrAuthorityImpl.java @@ -10,6 +10,7 @@ import java.io.IOException; import java.io.InputStream; import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedList; @@ -20,6 +21,7 @@ import org.apache.logging.log4j.Logger; import org.dspace.authority.AuthorityValue; import org.dspace.authority.SolrAuthorityInterface; +import org.dspace.external.OrcidConnectionException; import org.dspace.external.OrcidRestConnector; import org.dspace.external.provider.orcid.xml.XMLtoBio; import org.dspace.orcid.model.factory.OrcidFactoryUtils; @@ -142,9 +144,15 @@ public Person getBio(String id) { return null; } initializeAccessToken(); - InputStream bioDocument = orcidRestConnector.get(id + ((id.endsWith("/person")) ? "" : "/person"), accessToken); - XMLtoBio converter = new XMLtoBio(); - return converter.convertSinglePerson(bioDocument); + try { + InputStream bioDocument = orcidRestConnector.get(id + ((id.endsWith("/person")) ? "" : "/person"), + accessToken); + XMLtoBio converter = new XMLtoBio(); + return converter.convertSinglePerson(bioDocument); + } catch (OrcidConnectionException e) { + log.error("Error retrieving ORCID bio for ID=" + id, e); + return null; + } } @@ -167,29 +175,35 @@ public List queryBio(String text, int start, int rows) { // Check / init access token initializeAccessToken(); - String searchPath = "search?q=" + URLEncoder.encode(text) + "&start=" + start + "&rows=" + rows; + String searchPath = "search?q=" + URLEncoder.encode(text, StandardCharsets.UTF_8) + "&start=" + start + + "&rows=" + rows; log.debug("queryBio searchPath=" + searchPath + " accessToken=" + accessToken); - InputStream bioDocument = orcidRestConnector.get(searchPath, accessToken); - XMLtoBio converter = new XMLtoBio(); - List results = converter.convert(bioDocument); - List bios = new LinkedList<>(); - for (Result result : results) { - OrcidIdentifier orcidIdentifier = result.getOrcidIdentifier(); - if (orcidIdentifier != null) { - log.debug("Found OrcidId=" + orcidIdentifier.toString()); - String orcid = orcidIdentifier.getPath(); - Person bio = getBio(orcid); - if (bio != null) { - bios.add(bio); + try { + InputStream bioDocument = orcidRestConnector.get(searchPath, accessToken); + XMLtoBio converter = new XMLtoBio(); + List results = converter.convert(bioDocument); + List bios = new LinkedList<>(); + for (Result result : results) { + OrcidIdentifier orcidIdentifier = result.getOrcidIdentifier(); + if (orcidIdentifier != null) { + log.debug("Found OrcidId=" + orcidIdentifier); + String orcid = orcidIdentifier.getPath(); + Person bio = getBio(orcid); + if (bio != null) { + bios.add(bio); + } } } + try { + bioDocument.close(); + } catch (IOException e) { + log.error(e.getMessage(), e); + } + return bios; + } catch (OrcidConnectionException e) { + log.error("Error searching ORCID for query=" + text, e); + return Collections.emptyList(); } - try { - bioDocument.close(); - } catch (IOException e) { - log.error(e.getMessage(), e); - } - return bios; } /** diff --git a/dspace-api/src/main/java/org/dspace/authorize/AuthorizeServiceImpl.java b/dspace-api/src/main/java/org/dspace/authorize/AuthorizeServiceImpl.java index ec418163acd..189dbe3d0d0 100644 --- a/dspace-api/src/main/java/org/dspace/authorize/AuthorizeServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/authorize/AuthorizeServiceImpl.java @@ -741,15 +741,15 @@ public List getPoliciesActionFilterExceptRpType(Context c, DSpac /** * Checks that the context's current user is a community admin in the site by querying the solr database. + * This query doesn't use authorization inheritance because direct community admin is enough to perform this check. * * @param context context with the current user * @return true if the current user is a community admin in the site * false when this is not the case, or an exception occurred - * @throws java.sql.SQLException passed through. */ @Override - public boolean isCommunityAdmin(Context context) throws SQLException { - return performCheck(context, RESOURCE_TYPE_FIELD + ":" + IndexableCommunity.TYPE); + public boolean isCommunityAdmin(Context context) { + return performCheck(context, RESOURCE_TYPE_FIELD + ":" + IndexableCommunity.TYPE, false); } /** @@ -758,11 +758,10 @@ public boolean isCommunityAdmin(Context context) throws SQLException { * @param context context with the current user * @return true if the current user is a collection admin in the site * false when this is not the case, or an exception occurred - * @throws java.sql.SQLException passed through. */ @Override - public boolean isCollectionAdmin(Context context) throws SQLException { - return performCheck(context, RESOURCE_TYPE_FIELD + ":" + IndexableCollection.TYPE); + public boolean isCollectionAdmin(Context context) { + return performCheck(context, RESOURCE_TYPE_FIELD + ":" + IndexableCollection.TYPE, true); } /** @@ -771,26 +770,26 @@ public boolean isCollectionAdmin(Context context) throws SQLException { * @param context context with the current user * @return true if the current user is an item admin in the site * false when this is not the case, or an exception occurred - * @throws java.sql.SQLException passed through. */ @Override - public boolean isItemAdmin(Context context) throws SQLException { - return performCheck(context, RESOURCE_TYPE_FIELD + ":" + IndexableItem.TYPE); + public boolean isItemAdmin(Context context) { + return performCheck(context, RESOURCE_TYPE_FIELD + ":" + IndexableItem.TYPE, true); } /** * Checks that the context's current user is a community or collection admin in the site. + * This query doesn't use authorization inheritance because direct community/collection admin is enough to + * perform this check. * * @param context context with the current user * @return true if the current user is a community or collection admin in the site * false when this is not the case, or an exception occurred - * @throws java.sql.SQLException passed through. */ @Override - public boolean isComColAdmin(Context context) throws SQLException { + public boolean isComColAdmin(Context context) { return performCheck(context, "(" + RESOURCE_TYPE_FIELD + ":" + IndexableCommunity.TYPE + " OR " + - RESOURCE_TYPE_FIELD + ":" + IndexableCollection.TYPE + ")"); + RESOURCE_TYPE_FIELD + ":" + IndexableCollection.TYPE + ")", false); } /** @@ -805,11 +804,30 @@ public boolean isComColAdmin(Context context) throws SQLException { */ @Override public List findAdminAuthorizedCommunity(Context context, String query, int offset, int limit) - throws SearchServiceException, SQLException { + throws SearchServiceException { + return findAuthorizedCommunityByAction(context, query, Constants.ADMIN, offset, limit); + } + + /** + * Finds communities for which the logged in user has the rights specified by the action parameter. + * + * @param context the context whose user is checked against + * @param query the optional extra query + * @param action the action to check for + * @param offset the offset for pagination + * @param limit the amount of dso's to return + * @return a list of communities for which the logged in user has the rights specified by the action + * @throws SearchServiceException + */ + @Override + public List findAuthorizedCommunityByAction(Context context, String query, int action, int offset, + int limit) + throws SearchServiceException { List communities = new ArrayList<>(); + query = searchService.formatAutoCompleteQuery(query, "dc.title_sort"); query = formatCustomQuery(query); DiscoverResult discoverResult = getDiscoverResult(context, query + RESOURCE_TYPE_FIELD + ":" + - IndexableCommunity.TYPE, + IndexableCommunity.TYPE, action, true, offset, limit, null, null); for (IndexableObject solrCollections : discoverResult.getIndexableObjects()) { Community community = ((IndexableCommunity) solrCollections).getIndexedObject(); @@ -828,10 +846,26 @@ public List findAdminAuthorizedCommunity(Context context, String quer */ @Override public long countAdminAuthorizedCommunity(Context context, String query) - throws SearchServiceException, SQLException { + throws SearchServiceException { + return countAuthorizedCommunityByAction(context, query, Constants.ADMIN); + } + + /** + * Counts communities for which the current user has the rights specified by the action parameter. + * + * @param context context with the current user + * @param query the query for which to filter the results more + * @param action the action to check for + * @return the matching communities + * @throws SearchServiceException + */ + @Override + public long countAuthorizedCommunityByAction(Context context, String query, int action) + throws SearchServiceException { + query = searchService.formatAutoCompleteQuery(query, "dc.title_sort"); query = formatCustomQuery(query); DiscoverResult discoverResult = getDiscoverResult(context, query + RESOURCE_TYPE_FIELD + ":" + - IndexableCommunity.TYPE, + IndexableCommunity.TYPE, action, true, null, 0, null, null); return discoverResult.getTotalSearchResults(); } @@ -848,15 +882,34 @@ public long countAdminAuthorizedCommunity(Context context, String query) */ @Override public List findAdminAuthorizedCollection(Context context, String query, int offset, int limit) - throws SearchServiceException, SQLException { + throws SearchServiceException { + return findAuthorizedCollectionByAction(context, query, Constants.ADMIN, offset, limit); + } + + /** + * Finds collections for which the logged in user has the rights specified by the action parameter. + * + * @param context the context whose user is checked against + * @param query the optional extra query + * @param action the action to check for + * @param offset the offset for pagination + * @param limit the amount of dso's to return + * @return a list of collections for which the logged in user has the rights specified by the action + * @throws SearchServiceException + */ + @Override + public List findAuthorizedCollectionByAction(Context context, String query, + int action, int offset, int limit) + throws SearchServiceException { List collections = new ArrayList<>(); if (context.getCurrentUser() == null) { return collections; } + query = searchService.formatAutoCompleteQuery(query, "dc.title_sort"); query = formatCustomQuery(query); DiscoverResult discoverResult = getDiscoverResult(context, query + RESOURCE_TYPE_FIELD + ":" + - IndexableCollection.TYPE, + IndexableCollection.TYPE, action, true, offset, limit, CollectionService.SOLR_SORT_FIELD, SORT_ORDER.asc); for (IndexableObject solrCollections : discoverResult.getIndexableObjects()) { Collection collection = ((IndexableCollection) solrCollections).getIndexedObject(); @@ -875,31 +928,44 @@ public List findAdminAuthorizedCollection(Context context, String qu */ @Override public long countAdminAuthorizedCollection(Context context, String query) - throws SearchServiceException, SQLException { + throws SearchServiceException { + return countAuthorizedCollectionByAction(context, query, Constants.ADMIN); + } + + /** + * Counts collections for which the current user has the rights specified by the action parameter. + * + * @param context context with the current user + * @param query the query for which to filter the results more + * @param action the action to check for + * @return the matching collections + * @throws SearchServiceException + */ + @Override + public long countAuthorizedCollectionByAction(Context context, String query, int action) + throws SearchServiceException { + query = searchService.formatAutoCompleteQuery(query, "dc.title_sort"); query = formatCustomQuery(query); DiscoverResult discoverResult = getDiscoverResult(context, query + RESOURCE_TYPE_FIELD + ":" + - IndexableCollection.TYPE, - null, 0, null, null); + IndexableCollection.TYPE, action, + true, null, 0, null, null); return discoverResult.getTotalSearchResults(); } @Override public boolean isAccountManager(Context context) { - try { - return (canCommunityAdminManageAccounts() && isCommunityAdmin(context) - || canCollectionAdminManageAccounts() && isCollectionAdmin(context)); - } catch (SQLException e) { - throw new RuntimeException(e); - } + return (canCommunityAdminManageAccounts() && isCommunityAdmin(context) + || canCollectionAdminManageAccounts() && isCollectionAdmin(context)); } - private boolean performCheck(Context context, String query) throws SQLException { + private boolean performCheck(Context context, String query, boolean inheritAuthorizations) { if (context.getCurrentUser() == null) { return false; } try { - DiscoverResult discoverResult = getDiscoverResult(context, query, null, null, null, null); + DiscoverResult discoverResult = getDiscoverResult(context, query, Constants.ADMIN, inheritAuthorizations, + null, 0, null, null); if (discoverResult.getTotalSearchResults() > 0) { return true; } @@ -911,16 +977,11 @@ private boolean performCheck(Context context, String query) throws SQLException return false; } - private DiscoverResult getDiscoverResult(Context context, String query, Integer offset, Integer limit, - String sortField, SORT_ORDER sortOrder) - throws SearchServiceException, SQLException { - String groupQuery = getGroupToQuery(groupService.allMemberGroups(context, context.getCurrentUser())); + private DiscoverResult getDiscoverResult(Context context, String query, int action, boolean inheritAuthorizations, + Integer offset, Integer limit, String sortField, SORT_ORDER sortOrder) + throws SearchServiceException { DiscoverQuery discoverQuery = new DiscoverQuery(); - if (!this.isAdmin(context)) { - query = query + " AND (" + - "admin:e" + context.getCurrentUser().getID() + groupQuery + ")"; - } discoverQuery.setQuery(query); if (offset != null) { discoverQuery.setStart(offset); @@ -931,23 +992,12 @@ private DiscoverResult getDiscoverResult(Context context, String query, Integer if (sortField != null && sortOrder != null) { discoverQuery.setSortField(sortField, sortOrder); } + discoverQuery.addRequiredAuthorization(action); + discoverQuery.setInheritAuthorizations(inheritAuthorizations); return searchService.search(context, discoverQuery); } - private String getGroupToQuery(List groups) { - StringBuilder groupQuery = new StringBuilder(); - - if (groups != null) { - for (Group group: groups) { - groupQuery.append(" OR admin:g"); - groupQuery.append(group.getID()); - } - } - - return groupQuery.toString(); - } - private String formatCustomQuery(String query) { if (StringUtils.isBlank(query)) { return ""; diff --git a/dspace-api/src/main/java/org/dspace/authorize/dao/impl/ResourcePolicyDAOImpl.java b/dspace-api/src/main/java/org/dspace/authorize/dao/impl/ResourcePolicyDAOImpl.java index e4f3a0c057b..75d0e701b4a 100644 --- a/dspace-api/src/main/java/org/dspace/authorize/dao/impl/ResourcePolicyDAOImpl.java +++ b/dspace-api/src/main/java/org/dspace/authorize/dao/impl/ResourcePolicyDAOImpl.java @@ -165,7 +165,7 @@ public List findByEPersonGroupTypeIdAction(Context context, EPer (resourcePolicyRoot.get(ResourcePolicy_.epersonGroup).in(groups))) ) ); - return list(context, criteriaQuery, false, ResourcePolicy.class, 1, -1); + return list(context, criteriaQuery, false, ResourcePolicy.class, -1, -1); } @Override diff --git a/dspace-api/src/main/java/org/dspace/authorize/service/AuthorizeService.java b/dspace-api/src/main/java/org/dspace/authorize/service/AuthorizeService.java index e0a94833d76..12fae93c3e5 100644 --- a/dspace-api/src/main/java/org/dspace/authorize/service/AuthorizeService.java +++ b/dspace-api/src/main/java/org/dspace/authorize/service/AuthorizeService.java @@ -546,6 +546,20 @@ void switchPoliciesAction(Context context, DSpaceObject dso, int fromAction, int List findAdminAuthorizedCommunity(Context context, String query, int offset, int limit) throws SearchServiceException, SQLException; + /** + * Finds communities for which the logged in user has the rights specified by the action parameter. + * + * @param context the context whose user is checked against + * @param query the optional extra query + * @param action the action to check for + * @param offset the offset for pagination + * @param limit the amount of dso's to return + * @return a list of communities for which the logged in user has the rights specified by the action + * @throws SearchServiceException + */ + List findAuthorizedCommunityByAction(Context context, String query, int action, int offset, int limit) + throws SearchServiceException, SQLException; + /** * Counts communities for which the current user is admin, AND which match the query. * @@ -558,6 +572,18 @@ List findAdminAuthorizedCommunity(Context context, String query, int long countAdminAuthorizedCommunity(Context context, String query) throws SearchServiceException, SQLException; + /** + * Counts communities for which the current user has the rights specified by the action parameter. + * + * @param context context with the current user + * @param query the query for which to filter the results more + * @param action the action to check for + * @return the matching communities + * @throws SearchServiceException + */ + long countAuthorizedCommunityByAction(Context context, String query, int action) + throws SearchServiceException; + /** * Finds collections for which the current user is admin, AND which match the query. * @@ -567,10 +593,24 @@ long countAdminAuthorizedCommunity(Context context, String query) * @param limit used for pagination of the results * @return the matching collections * @throws SearchServiceException - * @throws SQLException */ List findAdminAuthorizedCollection(Context context, String query, int offset, int limit) - throws SearchServiceException, SQLException; + throws SearchServiceException; + + /** + * Finds collections for which the current user has the rights specified by the action parameter. + * + * @param context context with the current user + * @param query the query for which to filter the results more + * @param action the action to check for + * @param offset used for pagination of the results + * @param limit used for pagination of the results + * @return the matching collections + * @throws SearchServiceException + */ + List findAuthorizedCollectionByAction(Context context, String query, int action, int offset, + int limit) + throws SearchServiceException; /** * Counts collections for which the current user is admin, AND which match the query. @@ -582,7 +622,19 @@ List findAdminAuthorizedCollection(Context context, String query, in * @throws SQLException */ long countAdminAuthorizedCollection(Context context, String query) - throws SearchServiceException, SQLException; + throws SearchServiceException; + + /** + * Counts collections for which the current user has the rights specified by the action parameter. + * + * @param context context with the current user + * @param query the query for which to filter the results more + * @param action the action to check for + * @return the number of matching collections + * @throws SearchServiceException + */ + long countAuthorizedCollectionByAction(Context context, String query, int action) + throws SearchServiceException; /** * Returns true if the current user can manage accounts. diff --git a/dspace-api/src/main/java/org/dspace/browse/BrowseDAO.java b/dspace-api/src/main/java/org/dspace/browse/BrowseDAO.java index 4a00922cc5c..26983303a15 100644 --- a/dspace-api/src/main/java/org/dspace/browse/BrowseDAO.java +++ b/dspace-api/src/main/java/org/dspace/browse/BrowseDAO.java @@ -396,4 +396,6 @@ public interface BrowseDAO { public void setStartsWith(String startsWith); public String getStartsWith(); -} \ No newline at end of file + + public void setDateStartsWith(String dateStartsWith); +} 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 be7a34086a4..f7f5f2ff7df 100644 --- a/dspace-api/src/main/java/org/dspace/browse/BrowseEngine.java +++ b/dspace-api/src/main/java/org/dspace/browse/BrowseEngine.java @@ -203,12 +203,8 @@ private BrowseInfo browseByItem(BrowserScope bs) // get the table name that we are going to be getting our data from dao.setTable(browseIndex.getTableName()); - if (scope.getBrowseIndex() != null && OrderFormat.TITLE.equals(scope.getBrowseIndex().getDataType())) { - // For browsing by title, apply the same normalization applied to indexed titles - dao.setStartsWith(normalizeJumpToValue(scope.getStartsWith())); - } else { - dao.setStartsWith(StringUtils.lowerCase(scope.getStartsWith())); - } + // Set startsWith or dateStartsWith params on SolrBrowseDAO + addStartsWithParams(bs); // tell the browse query whether we are ascending or descending on the value dao.setAscending(scope.isAscending()); @@ -367,6 +363,30 @@ private BrowseInfo browseByItem(BrowserScope bs) } } + private void addStartsWithParams(BrowserScope bs) throws BrowseException { + if (StringUtils.isNotBlank(scope.getStartsWith())) { + boolean isDateBrowse = bs.getSortOption().getType().equals("date"); + if (!isDateBrowse) { + if (scope.getBrowseIndex() != null + && OrderFormat.TITLE.equals(scope.getBrowseIndex().getDataType())) { + // For browsing by title, apply the same normalization applied to indexed titles + dao.setStartsWith(normalizeJumpToValue(scope.getStartsWith())); + } else { + dao.setStartsWith(StringUtils.lowerCase(scope.getStartsWith())); + } + // clear the old date starts with + dao.setDateStartsWith(null); + } else { + // For "date" sort browses ({@code webui.itemlist.sort-option.*} config): + // sets a date specific filter where the startsWith query is the start date, + // eg `fq=bi_sort_*_sort:+["1940-02" TO + ]` + dao.setDateStartsWith(scope.getStartsWith().trim()); + // clear the old non date starts with + dao.setStartsWith(null); + } + } + } + /** * Browse the archive by single values (such as the name of an author). This * produces a BrowseInfo object that contains Strings as the results of diff --git a/dspace-api/src/main/java/org/dspace/browse/ItemCountDAOSolr.java b/dspace-api/src/main/java/org/dspace/browse/ItemCountDAOSolr.java index e4d0079fe20..723b49d63ae 100644 --- a/dspace-api/src/main/java/org/dspace/browse/ItemCountDAOSolr.java +++ b/dspace-api/src/main/java/org/dspace/browse/ItemCountDAOSolr.java @@ -7,121 +7,62 @@ */ package org.dspace.browse; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - +import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.SolrQuery; +import org.apache.solr.client.solrj.response.QueryResponse; import org.dspace.content.Collection; import org.dspace.content.Community; import org.dspace.content.DSpaceObject; import org.dspace.core.Context; -import org.dspace.discovery.DiscoverFacetField; -import org.dspace.discovery.DiscoverQuery; -import org.dspace.discovery.DiscoverResult; -import org.dspace.discovery.DiscoverResult.FacetResult; -import org.dspace.discovery.SearchService; -import org.dspace.discovery.SearchServiceException; -import org.dspace.discovery.configuration.DiscoveryConfigurationParameters; +import org.dspace.discovery.SolrSearchCore; import org.dspace.discovery.indexobject.IndexableItem; import org.springframework.beans.factory.annotation.Autowired; /** * Discovery (Solr) driver implementing ItemCountDAO interface to look up item - * count information in communities and collections. Caching operations are - * intentionally not implemented because Solr already is our cache. + * count information in communities and collections. + *

+ * Counts are computed by querying Solr for archived, non-withdrawn, discoverable + * items using {@code location.comm} / {@code location.coll} filters. + * The query returns only {@code numFound} (rows=0), making it very fast. */ public class ItemCountDAOSolr implements ItemCountDAO { - /** - * Log4j logger - */ - private static Logger log = org.apache.logging.log4j.LogManager.getLogger(ItemCountDAOSolr.class); - - /** - * Hold the communities item count obtained from SOLR after the first query. This only works - * well if the ItemCountDAO lifecycle is bound to the request lifecycle as - * it is now. If we switch to a Spring-based instantiation we should mark - * this bean as prototype - **/ - private Map communitiesCount = null; - - /** - * Hold the collection item count obtained from SOLR after the first query - **/ - private Map collectionsCount = null; + private static final Logger log = LogManager.getLogger(ItemCountDAOSolr.class); - /** - * Solr search service - */ @Autowired - protected SearchService searchService; + private SolrSearchCore solrSearchCore; - /** - * Get the count of the items in the given container. - * - * @param context DSpace context - * @param dso DspaceObject - * @return count - */ @Override public int getCount(Context context, DSpaceObject dso) { - loadCount(context); - Integer val = null; + String locationFilter; if (dso instanceof Collection) { - val = collectionsCount.get(dso.getID().toString()); + locationFilter = "location.coll:" + dso.getID().toString(); } else if (dso instanceof Community) { - val = communitiesCount.get(dso.getID().toString()); - } - - if (val != null) { - return val; + locationFilter = "location.comm:" + dso.getID().toString(); } else { return 0; } - } - /** - * make sure that the counts are actually fetched from Solr (if haven't been - * cached in a Map yet) - * - * @param context DSpace Context - */ - private void loadCount(Context context) { - if (communitiesCount != null || collectionsCount != null) { - return; - } - - communitiesCount = new HashMap<>(); - collectionsCount = new HashMap<>(); - - DiscoverQuery query = new DiscoverQuery(); - query.setFacetMinCount(1); - query.addFacetField(new DiscoverFacetField("location.comm", - DiscoveryConfigurationParameters.TYPE_STANDARD, -1, - DiscoveryConfigurationParameters.SORT.COUNT)); - query.addFacetField(new DiscoverFacetField("location.coll", - DiscoveryConfigurationParameters.TYPE_STANDARD, -1, - DiscoveryConfigurationParameters.SORT.COUNT)); - query.addFilterQueries("search.resourcetype:" + IndexableItem.TYPE); // count only items - query.addFilterQueries("NOT(discoverable:false)"); // only discoverable - query.addFilterQueries("withdrawn:false"); // only not withdrawn - query.addFilterQueries("archived:true"); // only archived - query.setMaxResults(0); - - DiscoverResult sResponse; try { - sResponse = searchService.search(context, query); - List commCount = sResponse.getFacetResult("location.comm"); - List collCount = sResponse.getFacetResult("location.coll"); - for (FacetResult c : commCount) { - communitiesCount.put(c.getAsFilterQuery(), (int) c.getCount()); - } - for (FacetResult c : collCount) { - collectionsCount.put(c.getAsFilterQuery(), (int) c.getCount()); + SolrClient solr = solrSearchCore.getSolr(); + if (solr == null) { + return 0; } - } catch (SearchServiceException e) { - log.error("Could not initialize Community/Collection Item Counts from Solr: ", e); + SolrQuery query = new SolrQuery("*:*"); + query.addFilterQuery(locationFilter); + query.addFilterQuery("search.resourcetype:" + IndexableItem.TYPE); + query.addFilterQuery("NOT(discoverable:false)"); + query.addFilterQuery("withdrawn:false"); + query.addFilterQuery("archived:true"); + query.setRows(0); + QueryResponse response = solr.query(query, solrSearchCore.REQUEST_METHOD); + return (int) response.getResults().getNumFound(); + } catch (Exception e) { + log.error("Error counting items in Solr for {}: ", dso.getID(), e); } + return 0; } } 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 a0a7725fa13..32841e6c4b3 100644 --- a/dspace-api/src/main/java/org/dspace/browse/SolrBrowseDAO.java +++ b/dspace-api/src/main/java/org/dspace/browse/SolrBrowseDAO.java @@ -11,6 +11,7 @@ import static org.dspace.discovery.SearchUtils.RESOURCE_TYPE_FIELD; import java.io.Serializable; +import java.time.YearMonth; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -99,6 +100,8 @@ public int compare(Object o1, Object o2) { private String startsWith = null; + private String dateStartsWith = null; + /** * field to look for value in */ @@ -221,10 +224,33 @@ private DiscoverResult getSolrResponse() throws BrowseException { } else if (valuePartial) { query.addFilterQueries("{!field f=" + facetField + "_partial}" + value); } + if (StringUtils.isNotBlank(startsWith) && orderField != null) { query.addFilterQueries( "bi_" + orderField + "_sort:" + ClientUtils.escapeQueryChars(startsWith) + "*"); } + if (StringUtils.isNotBlank(dateStartsWith)) { + if (!ascending) { + String raw = dateStartsWith.trim(); + String upperBound; + if (raw.length() == 4) { // YYYY + upperBound = raw + "-12-31"; + } else if (raw.length() == 7) { // YYYY-MM + YearMonth ym = YearMonth.parse(raw); + upperBound = ym.atEndOfMonth().toString(); + } else { // YYYY-MM-DD + upperBound = raw; + } + query.addFilterQueries("bi_" + orderField + "_sort" + ": [* TO \"" + upperBound + "\"]"); + } else { + query.addFilterQueries("bi_" + orderField + "_sort" + ": [\"" + dateStartsWith + "\" TO *]"); + } + } + if (StringUtils.isNotBlank(startsWith) && StringUtils.isNotBlank(dateStartsWith)) { + log.warn(String.format("dateStartsWith %s and startsWith %s both given, only one should " + + "be given since different type of sort filterquery applied depending on which is not blank", + dateStartsWith, startsWith)); + } // filter on item to be sure to don't include any other object // indexed in the Discovery Search core query.addFilterQueries("search.resourcetype:" + IndexableItem.TYPE); @@ -466,6 +492,11 @@ public int getLimit() { return limit; } + @Override + public void setDateStartsWith(String dateStartsWith) { + this.dateStartsWith = dateStartsWith; + } + /* * (non-Javadoc) * diff --git a/dspace-api/src/main/java/org/dspace/checker/CheckerCommand.java b/dspace-api/src/main/java/org/dspace/checker/CheckerCommand.java index ba503d83eb4..90e833c9f71 100644 --- a/dspace-api/src/main/java/org/dspace/checker/CheckerCommand.java +++ b/dspace-api/src/main/java/org/dspace/checker/CheckerCommand.java @@ -134,7 +134,7 @@ public void process() throws SQLException { collector.collect(context, info); } - context.uncacheEntity(bitstream); + context.commit(); bitstream = dispatcher.next(); } } diff --git a/dspace-api/src/main/java/org/dspace/checker/DailyReportEmailer.java b/dspace-api/src/main/java/org/dspace/checker/DailyReportEmailer.java index b291232e8b5..82c01bf0b9c 100644 --- a/dspace-api/src/main/java/org/dspace/checker/DailyReportEmailer.java +++ b/dspace-api/src/main/java/org/dspace/checker/DailyReportEmailer.java @@ -75,6 +75,7 @@ public void sendReport(File attachment, int numberOfBitstreams) email.setContent("Checker Report", "report is attached ..."); email.addAttachment(attachment, "checksum_checker_report.txt"); email.addRecipient(configurationService.getProperty("mail.admin")); + log.info("Sending checker report email to " + configurationService.getProperty("mail.admin")); email.send(); } } @@ -109,18 +110,19 @@ public static void main(String[] args) { Options options = new Options(); options.addOption("h", "help", false, "Help"); - options.addOption("d", "Deleted", false, - "Send E-mail report for all bitstreams set as deleted for today"); - options.addOption("m", "Missing", false, - "Send E-mail report for all bitstreams not found in assetstore for today"); - options.addOption("c", "Changed", false, - "Send E-mail report for all bitstreams where checksum has been changed for today"); - options.addOption("a", "All", false, - "Send all E-mail reports"); - options.addOption("u", "Unchecked", false, - "Send the Unchecked bitstream report"); - options.addOption("n", "Not Processed", false, - "Send E-mail report for all bitstreams set to longer be processed for today"); + options.addOption("d", "deleted", false, + "Send email report for all bitstreams set as deleted for today"); + options.addOption("m", "missing", false, + "Send email report for all bitstreams not found in assetstore for today"); + options.addOption("c", "changed", false, + "Send email report for all bitstreams where checksum has been changed for today"); + options.addOption("a", "all", false, + "Send all email reports (used by default)"); + options.addOption("u", "unchecked", false, + "Send the unchecked (i.e. recently added) bitstream email report"); + options.addOption("n", "not-processed", false, + "Send email report for all bitstreams set to no longer be processed for today (includes" + + " bitstreams marked as deleted or not found)"); try { line = parser.parse(options, args); @@ -133,13 +135,15 @@ public static void main(String[] args) { if (line.hasOption('h')) { HelpFormatter myhelp = new HelpFormatter(); - myhelp.printHelp("Checksum Reporter\n", options); - System.out.println("\nSend Deleted bitstream email report: DailyReportEmailer -d"); - System.out.println("\nSend Missing bitstreams email report: DailyReportEmailer -m"); - System.out.println("\nSend Checksum Changed email report: DailyReportEmailer -c"); - System.out.println("\nSend bitstream not to be processed email report: DailyReportEmailer -n"); - System.out.println("\nSend Un-checked bitstream report: DailyReportEmailer -u"); - System.out.println("\nSend All email reports: DailyReportEmailer"); + myhelp.printHelp("checker-emailer\n", options); + System.out.println("\nChecksum Checker Reporter usage examples:\n"); + System.out.println(" - Send all email reports: checker-emailer -a"); + System.out.println(" - Send deleted bitstream email report: checker-emailer -d"); + System.out.println(" - Send missing bitstreams email report: checker-emailer -m"); + System.out.println(" - Send checksum changed email report: checker-emailer -c"); + System.out.println(" - Send bitstream not to be processed email report: checker-emailer -n"); + System.out.println(" - Send unchecked bitstream email report: checker-emailer -u"); + System.out.println("\nDefault (no arguments) is equivalent to 'checker-emailer -a'\n"); System.exit(0); } @@ -191,7 +195,9 @@ public static void main(String[] args) { writer.write("\n--------------------------------- Report Spacer ---------------------------\n\n"); numBitstreams += reporter.getBitstreamNotFoundReport(context, yesterday, tomorrow, writer); writer.write("\n--------------------------------- Report Spacer ---------------------------\n\n"); - numBitstreams += reporter.getNotToBeProcessedReport(context, yesterday, tomorrow, writer); + // not to be processed report includes deleted and not found bitstreams so it is not necessary to + // include the sum in the counter + reporter.getNotToBeProcessedReport(context, yesterday, tomorrow, writer); writer.write("\n--------------------------------- Report Spacer ---------------------------\n\n"); numBitstreams += reporter.getUncheckedBitstreamsReport(context, writer); writer.write("\n--------------------------------- End Report ---------------------------\n\n"); diff --git a/dspace-api/src/main/java/org/dspace/checker/MostRecentChecksumServiceImpl.java b/dspace-api/src/main/java/org/dspace/checker/MostRecentChecksumServiceImpl.java index d267171aa0d..9ee777a3e15 100644 --- a/dspace-api/src/main/java/org/dspace/checker/MostRecentChecksumServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/checker/MostRecentChecksumServiceImpl.java @@ -90,61 +90,16 @@ public List findBitstreamResultTypeReport(Context context, D /** * Queries the bitstream table for bitstream IDs that are not yet in the * most_recent_checksum table, and inserts them into the - * most_recent_checksum and checksum_history tables. - * + * most_recent_checksum table. * @param context Context * @throws SQLException if database error */ @Override public void updateMissingBitstreams(Context context) throws SQLException { -// "insert into most_recent_checksum ( " -// + "bitstream_id, to_be_processed, expected_checksum, current_checksum, " -// + "last_process_start_date, last_process_end_date, " -// + "checksum_algorithm, matched_prev_checksum, result ) " -// + "select bitstream.bitstream_id, " -// + "CASE WHEN bitstream.deleted = false THEN true ELSE false END, " -// + "CASE WHEN bitstream.checksum IS NULL THEN '' ELSE bitstream.checksum END, " -// + "CASE WHEN bitstream.checksum IS NULL THEN '' ELSE bitstream.checksum END, " -// + "?, ?, CASE WHEN bitstream.checksum_algorithm IS NULL " -// + "THEN 'MD5' ELSE bitstream.checksum_algorithm END, true, " -// + "CASE WHEN bitstream.deleted = true THEN 'BITSTREAM_MARKED_DELETED' else 'CHECKSUM_MATCH' END " -// + "from bitstream where not exists( " -// + "select 'x' from most_recent_checksum " -// + "where most_recent_checksum.bitstream_id = bitstream.bitstream_id )"; - - List unknownBitstreams = bitstreamService.findBitstreamsWithNoRecentChecksum(context); - for (Bitstream bitstream : unknownBitstreams) { - log.info(bitstream + " " + bitstream.getID().toString() + " " + bitstream.getName()); - - MostRecentChecksum mostRecentChecksum = new MostRecentChecksum(); - mostRecentChecksum.setBitstream(bitstream); - //Only process if our bitstream isn't deleted - mostRecentChecksum.setToBeProcessed(!bitstream.isDeleted()); - if (bitstream.getChecksum() == null) { - mostRecentChecksum.setCurrentChecksum(""); - mostRecentChecksum.setExpectedChecksum(""); - } else { - mostRecentChecksum.setCurrentChecksum(bitstream.getChecksum()); - mostRecentChecksum.setExpectedChecksum(bitstream.getChecksum()); - } - mostRecentChecksum.setProcessStartDate(new Date()); - mostRecentChecksum.setProcessEndDate(new Date()); - if (bitstream.getChecksumAlgorithm() == null) { - mostRecentChecksum.setChecksumAlgorithm("MD5"); - } else { - mostRecentChecksum.setChecksumAlgorithm(bitstream.getChecksumAlgorithm()); - } - mostRecentChecksum.setMatchedPrevChecksum(true); - ChecksumResult checksumResult; - if (bitstream.isDeleted()) { - checksumResult = checksumResultService.findByCode(context, ChecksumResultCode.BITSTREAM_MARKED_DELETED); - } else { - checksumResult = checksumResultService.findByCode(context, ChecksumResultCode.CHECKSUM_MATCH); - } - mostRecentChecksum.setChecksumResult(checksumResult); - mostRecentChecksumDAO.create(context, mostRecentChecksum); - mostRecentChecksumDAO.save(context, mostRecentChecksum); - } + log.info("Retrieving missing bitsreams (bitstream IDs that are not yet in most_recent_checksum table)..."); + int updated = mostRecentChecksumDAO.updateMissingBitstreams(context); + log.info("Updated most_recent_checksum for " + updated + " bitstreams."); + log.info("Missing bitsreams processing done."); } @Override diff --git a/dspace-api/src/main/java/org/dspace/checker/SimpleReporterServiceImpl.java b/dspace-api/src/main/java/org/dspace/checker/SimpleReporterServiceImpl.java index ddefb28e1b5..6c69764fdc7 100644 --- a/dspace-api/src/main/java/org/dspace/checker/SimpleReporterServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/checker/SimpleReporterServiceImpl.java @@ -70,6 +70,7 @@ public int getDeletedBitstreamReport(Context context, Date startDate, Date endDa osw.write("\n"); osw.write(msg("deleted-bitstream-intro")); + osw.write(" "); osw.write(applyDateFormatShort(startDate)); osw.write(" "); osw.write(msg("date-range-to")); @@ -111,7 +112,6 @@ public int getChangedChecksumReport(Context context, Date startDate, Date endDat osw.write("\n"); osw.write(msg("checksum-did-not-match")); osw.write(" "); - osw.write("\n"); osw.write(applyDateFormatShort(startDate)); osw.write(" "); osw.write(msg("date-range-to")); diff --git a/dspace-api/src/main/java/org/dspace/checker/dao/MostRecentChecksumDAO.java b/dspace-api/src/main/java/org/dspace/checker/dao/MostRecentChecksumDAO.java index 56485c9b4b4..73a81e4d9b4 100644 --- a/dspace-api/src/main/java/org/dspace/checker/dao/MostRecentChecksumDAO.java +++ b/dspace-api/src/main/java/org/dspace/checker/dao/MostRecentChecksumDAO.java @@ -33,6 +33,8 @@ public List findByNotProcessedInDateRange(Context context, D public List findByResultTypeInDateRange(Context context, Date startDate, Date endDate, ChecksumResultCode resultCode) throws SQLException; + public int updateMissingBitstreams(Context context) throws SQLException; + public void deleteByBitstream(Context context, Bitstream bitstream) throws SQLException; public MostRecentChecksum getOldestRecord(Context context) throws SQLException; diff --git a/dspace-api/src/main/java/org/dspace/checker/dao/impl/MostRecentChecksumDAOImpl.java b/dspace-api/src/main/java/org/dspace/checker/dao/impl/MostRecentChecksumDAOImpl.java index a31e02cbab4..6ecd94f67f9 100644 --- a/dspace-api/src/main/java/org/dspace/checker/dao/impl/MostRecentChecksumDAOImpl.java +++ b/dspace-api/src/main/java/org/dspace/checker/dao/impl/MostRecentChecksumDAOImpl.java @@ -56,8 +56,8 @@ public List findByNotProcessedInDateRange(Context context, D criteriaQuery.where(criteriaBuilder.and( criteriaBuilder.equal(mostRecentChecksumRoot.get(MostRecentChecksum_.toBeProcessed), false), criteriaBuilder - .lessThanOrEqualTo(mostRecentChecksumRoot.get(MostRecentChecksum_.processStartDate), startDate), - criteriaBuilder.greaterThan(mostRecentChecksumRoot.get(MostRecentChecksum_.processStartDate), endDate) + .lessThanOrEqualTo(mostRecentChecksumRoot.get(MostRecentChecksum_.processStartDate), endDate), + criteriaBuilder.greaterThan(mostRecentChecksumRoot.get(MostRecentChecksum_.processStartDate), startDate) ) ); List orderList = new LinkedList<>(); @@ -66,6 +66,24 @@ public List findByNotProcessedInDateRange(Context context, D return list(context, criteriaQuery, false, MostRecentChecksum.class, -1, -1); } + @Override + public int updateMissingBitstreams(Context context) throws SQLException { + String hql = "INSERT INTO MostRecentChecksum(bitstream, toBeProcessed, expectedChecksum, currentChecksum, " + + "processStartDate, processEndDate, checksumAlgorithm, matchedPrevChecksum, checksumResult) " + + "SELECT b, " + + "CASE WHEN deleted = false THEN true ELSE false END, " + + "CASE WHEN checksum IS NULL THEN '' ELSE checksum END, " + + "CASE WHEN checksum IS NULL THEN '' ELSE checksum END, " + + "current_timestamp(), current_timestamp(), " + + "CASE WHEN checksumAlgorithm IS NULL THEN 'MD5' ELSE checksumAlgorithm END, " + + "CAST(1 AS boolean), " + + "(SELECT cr FROM ChecksumResult AS cr WHERE " + + "(resultCode = 'BITSTREAM_MARKED_DELETED' AND b.deleted = true) " + + "OR (resultCode = 'CHECKSUM_MATCH' AND b.deleted = false)) " + + "FROM Bitstream AS b WHERE NOT EXISTS(SELECT 'x' FROM MostRecentChecksum AS c WHERE c.bitstream = b)"; + Query query = createQuery(context, hql); + return query.executeUpdate(); + } @Override public MostRecentChecksum findByBitstream(Context context, Bitstream bitstream) throws SQLException { diff --git a/dspace-api/src/main/java/org/dspace/checker/service/SimpleReporterService.java b/dspace-api/src/main/java/org/dspace/checker/service/SimpleReporterService.java index 1dc56c20a3d..f3e0b43d889 100644 --- a/dspace-api/src/main/java/org/dspace/checker/service/SimpleReporterService.java +++ b/dspace-api/src/main/java/org/dspace/checker/service/SimpleReporterService.java @@ -72,7 +72,8 @@ public int getBitstreamNotFoundReport(Context context, Date startDate, Date endD /** * The bitstreams that were set to not be processed report for the specified - * date range. + * date range. This includes bitstreams that are marked as deleted and bitstreams + * that are not found from the assetstore. * * @param context context * @param startDate the start date range. diff --git a/dspace-api/src/main/java/org/dspace/content/BitstreamServiceImpl.java b/dspace-api/src/main/java/org/dspace/content/BitstreamServiceImpl.java index a29b6285374..6b5ee61a98f 100644 --- a/dspace-api/src/main/java/org/dspace/content/BitstreamServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/content/BitstreamServiceImpl.java @@ -20,6 +20,8 @@ import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.Logger; +import org.dspace.app.requestitem.RequestItem; +import org.dspace.app.requestitem.service.RequestItemService; import org.dspace.authorize.AuthorizeException; import org.dspace.authorize.service.AuthorizeService; import org.dspace.content.dao.BitstreamDAO; @@ -70,6 +72,8 @@ public class BitstreamServiceImpl extends DSpaceObjectServiceImpl imp protected ClarinLicenseResourceMappingService clarinLicenseResourceMappingService; @Autowired(required = true) protected ClarinItemService clarinItemService; + @Autowired(required = true) + protected RequestItemService requestItemService; protected BitstreamServiceImpl() { super(); @@ -295,6 +299,13 @@ public void delete(Context context, Bitstream bitstream) throws SQLException, Au // Remove all bundles from the bitstream object, clearing the connection in 2 ways bundles.clear(); + // Remove any RequestItem entities associated with this bitstream ensuring there are no requests referencing + // a deleted bitstream + Iterator requestItems = requestItemService.findByBitstreamId(context, bitstream.getID()); + while (requestItems.hasNext()) { + requestItemService.delete(context, requestItems.next()); + } + // Remove policies only after the bitstream has been updated (otherwise the current user has not WRITE rights) authorizeService.removeAllPolicies(context, bitstream); diff --git a/dspace-api/src/main/java/org/dspace/content/BundleServiceImpl.java b/dspace-api/src/main/java/org/dspace/content/BundleServiceImpl.java index 38a322baeff..42e0a2cef72 100644 --- a/dspace-api/src/main/java/org/dspace/content/BundleServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/content/BundleServiceImpl.java @@ -597,4 +597,8 @@ public Bundle findByLegacyId(Context context, int id) throws SQLException { public int countTotal(Context context) throws SQLException { return bundleDAO.countRows(context); } + + public int countBitstreams(Context context, Bundle bundle) throws SQLException { + return bundleDAO.countBitstreams(context, bundle); + } } diff --git a/dspace-api/src/main/java/org/dspace/content/CollectionServiceImpl.java b/dspace-api/src/main/java/org/dspace/content/CollectionServiceImpl.java index 32d7b2f8bc2..4f45570bf7c 100644 --- a/dspace-api/src/main/java/org/dspace/content/CollectionServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/content/CollectionServiceImpl.java @@ -13,18 +13,21 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.Iterator; +import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.MissingResourceException; import java.util.Objects; +import java.util.Queue; import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.Logger; -import org.apache.solr.client.solrj.util.ClientUtils; import org.dspace.app.util.AuthorizeUtil; import org.dspace.authorize.AuthorizeConfiguration; import org.dspace.authorize.AuthorizeException; @@ -846,6 +849,86 @@ public List findAuthorized(Context context, Community community, int return myResults; } + @Override + public List findAuthorized(Context context, Community community, List actions) + throws SQLException { + + List myCollections = new ArrayList<>(); + EPerson eperson = context.getCurrentUser(); + + //If eperson is Administrator return all colls or if a community is not null only the community's collections + if (authorizeService.isAdmin(context, eperson)) { + if (community != null) { + return community.getCollections(); + } + myCollections = this.findAll(context); + return myCollections; + } + + //Get the collections of the eperson where is is admin of a community + List directGroups = new ArrayList<>(eperson.getGroups()); // direct membership + Queue queue = new LinkedList<>(directGroups); + while (!queue.isEmpty()) { + Group current = queue.poll(); + List parents = current.getParentGroups(); + + for (Group parent : parents) { + if (directGroups.add(parent)) { + queue.add(parent); + } + } + } + + List resourcePolicies = resourcePolicyService + .find(context, eperson, directGroups, Constants.ADMIN, Constants.COMMUNITY); + List uuids = resourcePolicies.stream() + .map(policy -> policy.getdSpaceObject().getID()) + .collect(Collectors.toList()); + + List communities = uuids.stream() + .map(uuid -> { + try { + return communityService.find(context, uuid); + } catch (SQLException e) { + return null; //ignore that uuid + } + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + Set allCommunities = new HashSet<>(communities); + Set allCommAdminCollections = communities.stream() + .flatMap(cm -> cm.getCollections().stream()) + .collect(Collectors.toSet()); + Queue queueComm = new LinkedList<>(communities); + + while (!queueComm.isEmpty()) { + Community com = queueComm.poll(); + List childrenComms = com.getSubcommunities(); + for (Community childComm : childrenComms) { + if (allCommunities.add(childComm)) { + queueComm.add(childComm); + allCommAdminCollections.addAll(childComm.getCollections()); + } + } + } + + //Now get the collection when the eperson can deposit or is admin or is in a group with those privileges + myCollections = collectionDAO.findAuthorizedByEPerson(context, eperson, actions); + Set allCollections = new HashSet<>(myCollections); + //Join EPerson Community Admin Collections with Collection Admins + allCollections.addAll(allCommAdminCollections); + + List collsAllowed = new ArrayList<>(allCollections); + + //A community is passed, only the community's collections will be used and existing in eperson Authorizations + if (community != null) { + collsAllowed.retainAll(community.getCollections()); + } + + return collsAllowed; + } + @Override public Collection findByGroup(Context context, Group group) throws SQLException { return collectionDAO.findByGroup(context, group); @@ -957,7 +1040,7 @@ public String getDefaultReadGroupName(Collection collection, String typeOfGroupS @Override public List findCollectionsWithSubmit(String q, Context context, Community community, - int offset, int limit) throws SQLException, SearchServiceException { + int offset, int limit) throws SearchServiceException { List collections = new ArrayList<>(); DiscoverQuery discoverQuery = new DiscoverQuery(); @@ -974,8 +1057,8 @@ public List findCollectionsWithSubmit(String q, Context context, Com } @Override - public int countCollectionsWithSubmit(String q, Context context, Community community) - throws SQLException, SearchServiceException { + public int countCollectionsWithSubmit(Context context, String q, Community community) + throws SearchServiceException { DiscoverQuery discoverQuery = new DiscoverQuery(); discoverQuery.setMaxResults(0); @@ -997,29 +1080,12 @@ public int countCollectionsWithSubmit(String q, Context context, Community commu * terms. The terms are used to make also a prefix query on SOLR * so it can be used to implement an autosuggest feature over the collection name * @return discovery search result objects - * @throws SQLException if something goes wrong * @throws SearchServiceException if search error */ private DiscoverResult retrieveCollectionsWithSubmit(Context context, DiscoverQuery discoverQuery, String entityType, Community community, String q) - throws SQLException, SearchServiceException { - - StringBuilder query = new StringBuilder(); - EPerson currentUser = context.getCurrentUser(); - if (!authorizeService.isAdmin(context)) { - String userId = ""; - if (currentUser != null) { - userId = currentUser.getID().toString(); - } - query.append("submit:(e").append(userId); + throws SearchServiceException { - Set groups = groupService.allMemberGroupsSet(context, currentUser); - for (Group group : groups) { - query.append(" OR g").append(group.getID()); - } - query.append(")"); - discoverQuery.addFilterQueries(query.toString()); - } if (Objects.nonNull(community)) { discoverQuery.addFilterQueries("location.comm:" + community.getID().toString()); } @@ -1027,19 +1093,17 @@ private DiscoverResult retrieveCollectionsWithSubmit(Context context, DiscoverQu discoverQuery.addFilterQueries("search.entitytype:" + entityType); } if (StringUtils.isNotBlank(q)) { - StringBuilder buildQuery = new StringBuilder(); - String escapedQuery = ClientUtils.escapeQueryChars(q); - buildQuery.append("(").append(escapedQuery).append(" OR dc.title_sort:*") - .append(escapedQuery).append("*").append(")"); - discoverQuery.setQuery(buildQuery.toString()); + q = searchService.formatAutoCompleteQuery(q, "dc.title_sort"); + discoverQuery.setQuery(q); } + discoverQuery.addRequiredAuthorization(Constants.ADD); DiscoverResult resp = searchService.search(context, discoverQuery); return resp; } @Override - public List findCollectionsWithSubmit(String q, Context context, Community community, String entityType, - int offset, int limit) throws SQLException, SearchServiceException { + public List findCollectionsWithSubmit(Context context, String q, Community community, String entityType, + int offset, int limit) throws SearchServiceException { List collections = new ArrayList<>(); DiscoverQuery discoverQuery = new DiscoverQuery(); discoverQuery.setDSpaceObjectFilter(IndexableCollection.TYPE); @@ -1056,8 +1120,8 @@ public List findCollectionsWithSubmit(String q, Context context, Com } @Override - public int countCollectionsWithSubmit(String q, Context context, Community community, String entityType) - throws SQLException, SearchServiceException { + public int countCollectionsWithSubmit(Context context, String q, Community community, String entityType) + throws SearchServiceException { DiscoverQuery discoverQuery = new DiscoverQuery(); discoverQuery.setMaxResults(0); discoverQuery.setDSpaceObjectFilter(IndexableCollection.TYPE); diff --git a/dspace-api/src/main/java/org/dspace/content/EntityType.java b/dspace-api/src/main/java/org/dspace/content/EntityType.java index 20ab758a0b7..1c041e776e7 100644 --- a/dspace-api/src/main/java/org/dspace/content/EntityType.java +++ b/dspace-api/src/main/java/org/dspace/content/EntityType.java @@ -8,6 +8,7 @@ package org.dspace.content; import java.util.Objects; +import javax.persistence.Cacheable; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; @@ -19,6 +20,8 @@ import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.builder.HashCodeBuilder; import org.dspace.core.ReloadableEntity; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; /** * Class representing an EntityType @@ -26,6 +29,8 @@ * This also has a label that will be used to identify what kind of EntityType this object is */ @Entity +@Cacheable +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) @Table(name = "entity_type") public class EntityType implements ReloadableEntity { diff --git a/dspace-api/src/main/java/org/dspace/content/EntityTypeServiceImpl.java b/dspace-api/src/main/java/org/dspace/content/EntityTypeServiceImpl.java index 7df892cd56f..92a7b62e720 100644 --- a/dspace-api/src/main/java/org/dspace/content/EntityTypeServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/content/EntityTypeServiceImpl.java @@ -16,6 +16,7 @@ import java.util.Set; import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; import org.apache.solr.client.solrj.SolrQuery; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.response.FacetField; @@ -28,6 +29,7 @@ import org.dspace.content.service.EntityTypeService; import org.dspace.core.Constants; import org.dspace.core.Context; +import org.dspace.discovery.SearchService; import org.dspace.discovery.SolrSearchCore; import org.dspace.discovery.indexobject.IndexableCollection; import org.dspace.eperson.EPerson; @@ -49,6 +51,9 @@ public class EntityTypeServiceImpl implements EntityTypeService { @Autowired protected SolrSearchCore solrSearchCore; + @Autowired + protected SearchService searchService; + @Override public EntityType findByEntityType(Context context, String entityType) throws SQLException { return entityTypeDAO.findByEntityType(context, entityType); @@ -126,26 +131,34 @@ public List getSubmitAuthorizedTypes(Context context) throws SQLException, SolrServerException, IOException { List types = new ArrayList<>(); StringBuilder query = null; - EPerson currentUser = context.getCurrentUser(); if (!authorizeService.isAdmin(context)) { - String userId = ""; + EPerson currentUser = context.getCurrentUser(); + StringBuilder epersonAndGroupClause = new StringBuilder(); if (currentUser != null) { - userId = currentUser.getID().toString(); - query = new StringBuilder(); - query.append("submit:(e").append(userId); + epersonAndGroupClause.append("e").append(currentUser.getID()); } - + //Retrieve all the groups the current user is a member of Set groups = groupService.allMemberGroupsSet(context, currentUser); for (Group group : groups) { - if (query == null) { - query = new StringBuilder(); - query.append("submit:(g"); + if (epersonAndGroupClause.length() > 0) { + epersonAndGroupClause.append(" OR g").append(group.getID()); } else { - query.append(" OR g"); + epersonAndGroupClause.append("g").append(group.getID()); } - query.append(group.getID()); } - query.append(")"); + + if (epersonAndGroupClause.length() == 0) { + // No user or groups, no authorized types + return new ArrayList<>(); + } + query = new StringBuilder(); + query.append("submit:(").append(epersonAndGroupClause).append(")"); + query.append(" OR ").append("admin:(").append(epersonAndGroupClause).append(")"); + String locations = searchService.createLocationQueryForAdministrableDSOs(epersonAndGroupClause.toString()); + if (StringUtils.isNotBlank(locations)) { + query.append(" OR "); + query.append(locations); + } } SolrQuery sQuery = new SolrQuery("*:*"); 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 9c853613a94..d3898a4fe06 100644 --- a/dspace-api/src/main/java/org/dspace/content/ItemServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/content/ItemServiceImpl.java @@ -22,7 +22,6 @@ import java.util.UUID; import java.util.function.Supplier; import java.util.stream.Collectors; -import java.util.stream.Stream; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; @@ -1145,11 +1144,6 @@ public void adjustItemPolicies(Context context, Item item, Collection collection public void move(Context context, Item item, Collection from, Collection to) throws SQLException, AuthorizeException, IOException { - // If the two collections are the same, do nothing. - if (from.equals(to)) { - return; - } - // Use the normal move method, and default to not inherit permissions this.move(context, item, from, to, false); } @@ -1164,6 +1158,11 @@ public void move(Context context, Item item, Collection from, Collection to, boo authorizeService.authorizeAction(context, item, Constants.WRITE); } + // If the two collections are the same, do nothing. + if (from.equals(to)) { + return; + } + // Move the Item from one Collection to the other collectionService.addItem(context, to, item); collectionService.removeItem(context, from, item); @@ -1266,43 +1265,41 @@ public boolean canEdit(Context context, Item item) throws SQLException { * * @param context DSpace context * @param discoverQuery + * @param q query string * @return discovery search result objects - * @throws SQLException if something goes wrong * @throws SearchServiceException if search error */ - private DiscoverResult retrieveItemsWithEdit(Context context, DiscoverQuery discoverQuery) - throws SQLException, SearchServiceException { - EPerson currentUser = context.getCurrentUser(); - if (!authorizeService.isAdmin(context)) { - String userId = currentUser != null ? "e" + currentUser.getID().toString() : "e"; - Stream groupIds = groupService.allMemberGroupsSet(context, currentUser).stream() - .map(group -> "g" + group.getID()); - String query = Stream.concat(Stream.of(userId), groupIds) - .collect(Collectors.joining(" OR ", "edit:(", ")")); - discoverQuery.addFilterQueries(query); - } + private DiscoverResult retrieveItemsWithEdit(Context context, DiscoverQuery discoverQuery, String q) + throws SearchServiceException { + if (StringUtils.isNotBlank(q)) { + // Although not all items will have a metadata dc.title, we use it for autocomplete because it is the + // most common. Ideally, we should use a field that all indexed items have + q = searchService.formatAutoCompleteQuery(q, "dc.title_sort"); + discoverQuery.setQuery(q); + } + discoverQuery.addRequiredAuthorization(Constants.WRITE); return searchService.search(context, discoverQuery); } @Override - public List findItemsWithEdit(Context context, int offset, int limit) - throws SQLException, SearchServiceException { + public List findItemsWithEdit(Context context, String q, int offset, int limit) + throws SearchServiceException { DiscoverQuery discoverQuery = new DiscoverQuery(); discoverQuery.setDSpaceObjectFilter(IndexableItem.TYPE); discoverQuery.setStart(offset); discoverQuery.setMaxResults(limit); - DiscoverResult resp = retrieveItemsWithEdit(context, discoverQuery); + DiscoverResult resp = retrieveItemsWithEdit(context, discoverQuery, q); return resp.getIndexableObjects().stream() .map(solrItems -> ((IndexableItem) solrItems).getIndexedObject()) .collect(Collectors.toList()); } @Override - public int countItemsWithEdit(Context context) throws SQLException, SearchServiceException { + public int countItemsWithEdit(Context context, String q) throws SearchServiceException { DiscoverQuery discoverQuery = new DiscoverQuery(); discoverQuery.setMaxResults(0); discoverQuery.setDSpaceObjectFilter(IndexableItem.TYPE); - DiscoverResult resp = retrieveItemsWithEdit(context, discoverQuery); + DiscoverResult resp = retrieveItemsWithEdit(context, discoverQuery, q); return (int) resp.getTotalSearchResults(); } diff --git a/dspace-api/src/main/java/org/dspace/content/RelationshipType.java b/dspace-api/src/main/java/org/dspace/content/RelationshipType.java index 5e6941052b8..5b89911e1f7 100644 --- a/dspace-api/src/main/java/org/dspace/content/RelationshipType.java +++ b/dspace-api/src/main/java/org/dspace/content/RelationshipType.java @@ -7,6 +7,7 @@ */ package org.dspace.content; +import javax.persistence.Cacheable; import javax.persistence.CascadeType; import javax.persistence.Column; import javax.persistence.Entity; @@ -21,6 +22,8 @@ import org.dspace.core.Context; import org.dspace.core.ReloadableEntity; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; /** * Class representing a RelationshipType @@ -31,6 +34,8 @@ * The cardinality properties describe how many of each relations this relationshipType can support */ @Entity +@Cacheable +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) @Table(name = "relationship_type") public class RelationshipType implements ReloadableEntity { 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 444332df97d..746e0579dcf 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 @@ -34,35 +34,48 @@ * from {@code ${dspace.dir}/config/controlled-vocabularies/*.xml} and turns * them into autocompleting authorities. * - * Configuration: This MUST be configured as a self-named plugin, e.g.: {@code - * plugin.selfnamed.org.dspace.content.authority.ChoiceAuthority = \ + *

Configuration: This MUST be configured as a self-named plugin, e.g.: {@code + * plugin.selfnamed.org.dspace.content.authority.ChoiceAuthority = * org.dspace.content.authority.DSpaceControlledVocabulary * } * - * It AUTOMATICALLY configures a plugin instance for each XML file in the + *

It AUTOMATICALLY configures a plugin instance for each XML file in the * controlled vocabularies directory. The name of the plugin is the basename of * the file; e.g., {@code ${dspace.dir}/config/controlled-vocabularies/nsi.xml} * would generate a plugin called "nsi". * - * Each configured plugin comes with three configuration options: {@code - * vocabulary.plugin._plugin_.hierarchy.store = - * # Store entire hierarchy along with selected value. Default: TRUE - * vocabulary.plugin._plugin_.hierarchy.suggest = - * # Display entire hierarchy in the suggestion list. Default: TRUE - * vocabulary.plugin._plugin_.delimiter = "" - * # Delimiter to use when building hierarchy strings. Default: "::" - * } + *

Each configured plugin comes with three configuration options: + *

    + *
  • {@code vocabulary.plugin._plugin_.hierarchy.store = } + * # Store entire hierarchy along with selected value. Default: TRUE
  • + *
  • {@code vocabulary.plugin._plugin_.hierarchy.suggest = + * # Display entire hierarchy in the suggestion list. Default: TRUE}
  • + *
  • {@code vocabulary.plugin._plugin_.delimiter = "" + * # Delimiter to use when building hierarchy strings. Default: "::"}
  • + *
* * @author Michael B. Klein */ public class DSpaceControlledVocabulary extends SelfNamedPlugin implements HierarchicalAuthority { - private static Logger log = org.apache.logging.log4j.LogManager.getLogger(DSpaceControlledVocabulary.class); - protected static String xpathTemplate = "//node[contains(translate(@label,'ABCDEFGHIJKLMNOPQRSTUVWXYZ'," + - "'abcdefghijklmnopqrstuvwxyz'),'%s')]"; - protected static String idTemplate = "//node[@id = '%s']"; - protected static String labelTemplate = "//node[@label = '%s']"; + private static final Logger log = org.apache.logging.log4j.LogManager.getLogger(); + protected static final String xpathTemplate; + static { + StringBuilder upper = new StringBuilder(); + StringBuilder lower = new StringBuilder(); + for (int cp = 'A'; cp <= Character.MAX_CODE_POINT; cp++) { + if (Character.isLetter(cp) && Character.isUpperCase(cp)) { + int lcp = Character.toLowerCase(cp); + upper.appendCodePoint(cp); + lower.appendCodePoint(lcp); + } + } + xpathTemplate = "//node[contains(translate(@label,'" + upper + "','" + lower + "'),%s)]"; + } + protected static String idTemplate = "//node[@id = %s]"; + protected static String idTemplateQuoted = "//node[@id = '%s']"; + 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 pluginNames[] = null; @@ -106,7 +119,7 @@ public boolean accept(File dir, String name) { File.separator + "config" + File.separator + "controlled-vocabularies"; String[] xmlFiles = (new File(vocabulariesPath)).list(new xmlFilter()); - List names = new ArrayList(); + List names = new ArrayList<>(); for (String filename : xmlFiles) { names.add((new File(filename)).getName().replace(".xml", "")); } @@ -162,14 +175,23 @@ protected String buildString(Node node) { public Choices getMatches(String text, int start, int limit, String locale) { init(); log.debug("Getting matches for '" + text + "'"); - String xpathExpression = ""; String[] textHierarchy = text.split(hierarchyDelimiter, -1); + StringBuilder xpathExpressionBuilder = new StringBuilder(); for (int i = 0; i < textHierarchy.length; i++) { - xpathExpression += String.format(xpathTemplate, textHierarchy[i].replaceAll("'", "'").toLowerCase()); + xpathExpressionBuilder.append(String.format(xpathTemplate, "$var" + i)); } + String xpathExpression = xpathExpressionBuilder.toString(); XPath xpath = XPathFactory.newInstance().newXPath(); - int total = 0; - List choices = new ArrayList(); + xpath.setXPathVariableResolver(variableName -> { + String varName = variableName.getLocalPart(); + if (varName.startsWith("var")) { + int index = Integer.parseInt(varName.substring(3)); + return textHierarchy[index].toLowerCase(); + } + throw new IllegalArgumentException("Unexpected variable: " + varName); + }); + int total; + List choices; try { NodeList results = (NodeList) xpath.evaluate(xpathExpression, vocabulary, XPathConstants.NODESET); total = results.getLength(); @@ -185,14 +207,23 @@ public Choices getMatches(String text, int start, int limit, String locale) { @Override public Choices getBestMatch(String text, String locale) { init(); - log.debug("Getting best matches for '" + text + "'"); - String xpathExpression = ""; + log.debug("Getting best matches for {}'", text); String[] textHierarchy = text.split(hierarchyDelimiter, -1); + StringBuilder xpathExpressionBuilder = new StringBuilder(); for (int i = 0; i < textHierarchy.length; i++) { - xpathExpression += String.format(labelTemplate, textHierarchy[i].replaceAll("'", "'")); + xpathExpressionBuilder.append(String.format(labelTemplate, "$var" + i)); } + String xpathExpression = xpathExpressionBuilder.toString(); XPath xpath = XPathFactory.newInstance().newXPath(); - List choices = new ArrayList(); + xpath.setXPathVariableResolver(variableName -> { + String varName = variableName.getLocalPart(); + if (varName.startsWith("var")) { + int index = Integer.parseInt(varName.substring(3)); + return textHierarchy[index]; + } + throw new IllegalArgumentException("Unexpected variable: " + varName); + }); + List choices; try { NodeList results = (NodeList) xpath.evaluate(xpathExpression, vocabulary, XPathConstants.NODESET); choices = getChoicesFromNodeList(results, 0, 1); @@ -240,7 +271,7 @@ public Choices getTopChoices(String authorityName, int start, int limit, String @Override public Choices getChoicesByParent(String authorityName, String parentId, int start, int limit, String locale) { init(); - String xpathExpression = String.format(idTemplate, parentId); + String xpathExpression = String.format(idTemplateQuoted, parentId); return getChoicesByXpath(xpathExpression, start, limit); } @@ -264,15 +295,12 @@ public Integer getPreloadLevel() { } private boolean isRootElement(Node node) { - if (node != null && node.getOwnerDocument().getDocumentElement().equals(node)) { - return true; - } - return false; + return node != null && node.getOwnerDocument().getDocumentElement().equals(node); } private Node getNode(String key) throws XPathExpressionException { init(); - String xpathExpression = String.format(idTemplate, key); + String xpathExpression = String.format(idTemplateQuoted, key); Node node = getNodeFromXPath(xpathExpression); return node; } @@ -284,7 +312,7 @@ private Node getNodeFromXPath(String xpathExpression) throws XPathExpressionExce } private List getChoicesFromNodeList(NodeList results, int start, int limit) { - List choices = new ArrayList(); + List choices = new ArrayList<>(); for (int i = 0; i < results.getLength(); i++) { if (i < start) { continue; @@ -303,14 +331,14 @@ private List getChoicesFromNodeList(NodeList results, int start, int lim private Map addOtherInformation(String parentCurr, String noteCurr, List childrenCurr, String authorityCurr) { - Map extras = new HashMap(); + Map extras = new HashMap<>(); if (StringUtils.isNotBlank(parentCurr)) { extras.put("parent", parentCurr); } if (StringUtils.isNotBlank(noteCurr)) { extras.put("note", noteCurr); } - if (childrenCurr.size() > 0) { + if (!childrenCurr.isEmpty()) { extras.put("hasChildren", "true"); } else { extras.put("hasChildren", "false"); @@ -368,7 +396,7 @@ private String getNote(Node node) { } private List getChildren(Node node) { - List children = new ArrayList(); + List children = new ArrayList<>(); NodeList childNodes = node.getChildNodes(); for (int ci = 0; ci < childNodes.getLength(); ci++) { Node firstChild = childNodes.item(ci); @@ -391,7 +419,7 @@ private List getChildren(Node node) { private boolean isSelectable(Node node) { Node selectableAttr = node.getAttributes().getNamedItem("selectable"); if (null != selectableAttr) { - return Boolean.valueOf(selectableAttr.getNodeValue()); + return Boolean.parseBoolean(selectableAttr.getNodeValue()); } else { // Default is true return true; } @@ -418,7 +446,7 @@ private String getAuthority(Node node) { } private Choices getChoicesByXpath(String xpathExpression, int start, int limit) { - List choices = new ArrayList(); + List choices = new ArrayList<>(); XPath xpath = XPathFactory.newInstance().newXPath(); try { Node parentNode = (Node) xpath.evaluate(xpathExpression, vocabulary, XPathConstants.NODE); diff --git a/dspace-api/src/main/java/org/dspace/content/crosswalk/CrosswalkMetadataValidator.java b/dspace-api/src/main/java/org/dspace/content/crosswalk/CrosswalkMetadataValidator.java index b1be458a255..1bcc1c9ba9f 100644 --- a/dspace-api/src/main/java/org/dspace/content/crosswalk/CrosswalkMetadataValidator.java +++ b/dspace-api/src/main/java/org/dspace/content/crosswalk/CrosswalkMetadataValidator.java @@ -107,9 +107,12 @@ public MetadataField checkMetadata(Context context, String schema, String elemen e.printStackTrace(); } } else if (!fieldChoice.equals("ignore")) { - throw new CrosswalkException( - "The '" + element + "." + qualifier + "' element has not been defined in this DSpace " + - "instance. "); + throw new CrosswalkException(String.format( + "The '%s.%s%s' element has not been defined in this DSpace instance.", + mdSchema.getName(), + element, + qualifier == null ? "" : ("." + qualifier) + )); } } } diff --git a/dspace-api/src/main/java/org/dspace/content/crosswalk/LicenseStreamDisseminationCrosswalk.java b/dspace-api/src/main/java/org/dspace/content/crosswalk/LicenseStreamDisseminationCrosswalk.java index 46858747870..b1854bfd85a 100644 --- a/dspace-api/src/main/java/org/dspace/content/crosswalk/LicenseStreamDisseminationCrosswalk.java +++ b/dspace-api/src/main/java/org/dspace/content/crosswalk/LicenseStreamDisseminationCrosswalk.java @@ -8,6 +8,7 @@ package org.dspace.content.crosswalk; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.sql.SQLException; @@ -56,7 +57,12 @@ public void disseminate(Context context, DSpaceObject dso, OutputStream out) Bitstream licenseBs = PackageUtils.findDepositLicense(context, (Item) dso); if (licenseBs != null) { - Utils.copy(bitstreamService.retrieve(context, licenseBs), out); + try (final InputStream bitInputStream = bitstreamService.retrieve(context, licenseBs)) { + Utils.copy(bitInputStream, out); + } catch (Exception e) { + log.warn("Could not retrieve license file for Item with UUID={}. " + + "Leaving it out of generated package. Error{}", dso.getID(), e.getMessage()); + } } } } diff --git a/dspace-api/src/main/java/org/dspace/content/crosswalk/PREMISCrosswalk.java b/dspace-api/src/main/java/org/dspace/content/crosswalk/PREMISCrosswalk.java index 39b6c8f29c8..6b6c0fd7c5a 100644 --- a/dspace-api/src/main/java/org/dspace/content/crosswalk/PREMISCrosswalk.java +++ b/dspace-api/src/main/java/org/dspace/content/crosswalk/PREMISCrosswalk.java @@ -20,9 +20,7 @@ import org.dspace.authorize.AuthorizeException; import org.dspace.content.Bitstream; import org.dspace.content.BitstreamFormat; -import org.dspace.content.Bundle; import org.dspace.content.DSpaceObject; -import org.dspace.content.Item; import org.dspace.content.factory.ContentServiceFactory; import org.dspace.content.service.BitstreamFormatService; import org.dspace.content.service.BitstreamService; @@ -224,29 +222,17 @@ public Element disseminateElement(Context context, DSpaceObject dso) // c. made-up name based on sequence ID and extension. String sid = String.valueOf(bitstream.getSequenceID()); String baseUrl = configurationService.getProperty("dspace.ui.url"); - String handle = null; - // get handle of parent Item of this bitstream, if there is one: - List bn = bitstream.getBundles(); - if (bn.size() > 0) { - List bi = bn.get(0).getItems(); - if (bi.size() > 0) { - handle = bi.get(0).getHandle(); - } - } // get or make up name for bitstream: String bsName = bitstream.getName(); if (bsName == null) { List ext = bitstream.getFormat(context).getExtensions(); bsName = "bitstream_" + sid + (ext.size() > 0 ? ext.get(0) : ""); } - if (handle != null && baseUrl != null) { + if (baseUrl != null) { oiv.setText(baseUrl - + "/bitstream/" - + URLEncoder.encode(handle, "UTF-8") - + "/" - + sid - + "/" - + URLEncoder.encode(bsName, "UTF-8")); + + "/bitstreams/" + + bitstream.getID() + + "/download"); } else { oiv.setText(URLEncoder.encode(bsName, "UTF-8")); } diff --git a/dspace-api/src/main/java/org/dspace/content/dao/BundleDAO.java b/dspace-api/src/main/java/org/dspace/content/dao/BundleDAO.java index da7435d4664..99abd84eabc 100644 --- a/dspace-api/src/main/java/org/dspace/content/dao/BundleDAO.java +++ b/dspace-api/src/main/java/org/dspace/content/dao/BundleDAO.java @@ -22,4 +22,6 @@ */ public interface BundleDAO extends DSpaceObjectLegacySupportDAO { int countRows(Context context) throws SQLException; + + int countBitstreams(Context context, Bundle bundle) throws SQLException; } diff --git a/dspace-api/src/main/java/org/dspace/content/dao/CollectionDAO.java b/dspace-api/src/main/java/org/dspace/content/dao/CollectionDAO.java index 6bb65bbb46d..13bcf5f52c0 100644 --- a/dspace-api/src/main/java/org/dspace/content/dao/CollectionDAO.java +++ b/dspace-api/src/main/java/org/dspace/content/dao/CollectionDAO.java @@ -48,6 +48,18 @@ public List findAll(Context context, MetadataField order, Integer li List findAuthorizedByGroup(Context context, EPerson ePerson, List actions) throws SQLException; + /** + * Get all authorized collections of the current EPerson + * + * @param context DSpace context object + * @param ePerson the current EPerson + * @param actions list of actionsID ADD, READ, etc. + * @return the collections the eperson is defined + * @throws SQLException if database error + */ + List findAuthorizedByEPerson(Context context, EPerson ePerson, List actions) + throws SQLException; + List findCollectionsWithSubscribers(Context context) throws SQLException; int countRows(Context context) throws SQLException; diff --git a/dspace-api/src/main/java/org/dspace/content/dao/impl/BundleDAOImpl.java b/dspace-api/src/main/java/org/dspace/content/dao/impl/BundleDAOImpl.java index 99163610849..08d6342e267 100644 --- a/dspace-api/src/main/java/org/dspace/content/dao/impl/BundleDAOImpl.java +++ b/dspace-api/src/main/java/org/dspace/content/dao/impl/BundleDAOImpl.java @@ -8,6 +8,7 @@ package org.dspace.content.dao.impl; import java.sql.SQLException; +import javax.persistence.Query; import org.dspace.content.Bundle; import org.dspace.content.dao.BundleDAO; @@ -31,4 +32,13 @@ protected BundleDAOImpl() { public int countRows(Context context) throws SQLException { return count(createQuery(context, "SELECT count(*) from Bundle")); } + + @Override + public int countBitstreams(Context context, Bundle bundle) throws SQLException { + Query query = createQuery( + context, "SELECT count(bi.id) from Bundle bu join bu.bitstreams bi where bu.id = :bundleID" + ); + query.setParameter("bundleID", bundle.getID()); + return count(query); + } } diff --git a/dspace-api/src/main/java/org/dspace/content/dao/impl/CollectionDAOImpl.java b/dspace-api/src/main/java/org/dspace/content/dao/impl/CollectionDAOImpl.java index befa1397a8e..e54542eda2d 100644 --- a/dspace-api/src/main/java/org/dspace/content/dao/impl/CollectionDAOImpl.java +++ b/dspace-api/src/main/java/org/dspace/content/dao/impl/CollectionDAOImpl.java @@ -10,8 +10,13 @@ import java.sql.SQLException; import java.util.AbstractMap; import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.UUID; import javax.persistence.Query; import javax.persistence.criteria.CriteriaBuilder; import javax.persistence.criteria.CriteriaQuery; @@ -19,6 +24,7 @@ import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Root; +import org.apache.logging.log4j.Logger; import org.dspace.authorize.ResourcePolicy; import org.dspace.authorize.ResourcePolicy_; import org.dspace.content.Collection; @@ -40,6 +46,11 @@ * @author kevinvandevelde at atmire.com */ public class CollectionDAOImpl extends AbstractHibernateDSODAO implements CollectionDAO { + /** + * log4j logger + */ + private static Logger log = org.apache.logging.log4j.LogManager.getLogger(CollectionDAOImpl.class); + protected CollectionDAOImpl() { super(); } @@ -157,9 +168,103 @@ public List findAuthorizedByGroup(Context context, EPerson ePerson, } + /** + * Get all authorized collections of the current EPerson + * + * @param context DSpace context object + * @param ePerson the current EPerson + * @param actions list of actionsID ADD, READ, etc. + * @return the collections the eperson is defined + * @throws SQLException if database error + */ + @Override + public List findAuthorizedByEPerson(Context context, EPerson ePerson, List actions) + throws SQLException { + + //NOTE steps 1) and 2) removes the need of WITH RECURSIVE and a NativeQuery + + // 1) Get all groups a eperson belongs + /*ArrayList<>(ePerson.getGroups()) - This ensures you have a concrete copy and can modify it safely. + instead if List directGroups = ePerson.getGroups(); + Also - Can be done using this query: + List directGroups = createQuery(context, """ + SELECT g + FROM Group g + JOIN g.epeople e + WHERE e.id = :epersonId + """) + .setParameter("epersonId", ePerson.getID()) + .getResultList(); + */ + List directGroups = new ArrayList<>(ePerson.getGroups()); // direct membership + + // 2) Expand hierarquy of groups in memory (recursively) + Set allGroups = new HashSet<>(directGroups); + Queue queue = new LinkedList<>(directGroups); + + /* + * Using the query avoids the change of the getParentGroups visibility in Group + * The List parents = current.getParentGroups() could be achieved using: + * List parents = createQuery(context,""" + SELECT g + FROM Group g + JOIN g.groups child + WHERE child = :child + """) + */ + // //current.getMemberGroups()- Making public getParentGroups in Group Class (why it isn't already public?) + while (!queue.isEmpty()) { + Group current = queue.poll(); + List parents = current.getParentGroups(); + + for (Group parent : parents) { + if (allGroups.add(parent)) { + queue.add(parent); + } + } + } + + CriteriaBuilder cb = getCriteriaBuilder(context); + CriteriaQuery cq = getCriteriaQuery(cb, Collection.class); + Root collectionRoot = cq.from(Collection.class); + + // Join to ResourcePolicy using metamodel + Join rpJoin = collectionRoot.join("resourcePolicies"); + // Use metamodel for typesafe access + cq.select(collectionRoot).distinct(true); + + List predicates = new ArrayList<>(actions.size()); + // WHERE rp.resourceTypeId = :resourceType + predicates.add(cb.equal(rpJoin.get(ResourcePolicy_.resourceTypeId), Constants.COLLECTION)); + // AND (:hasActions = false OR rp.actionId IN :actionIds) + if (actions != null && !actions.isEmpty()) { + predicates.add(rpJoin.get(ResourcePolicy_.actionId).in(actions)); + } + + // AND (rp.eperson.id = :epersonId OR (:hasGroups = true AND rp.epersonGroup.id IN :groupIds)) + Predicate epersonPredicate = cb.equal( + rpJoin.get(ResourcePolicy_.eperson), ePerson + ); + // Using only groups instead of groupsIDs + Predicate groupPredicate = cb.disjunction(); // false by default + if (allGroups != null && !allGroups.isEmpty()) { + groupPredicate = rpJoin.get(ResourcePolicy_.epersonGroup).in(allGroups); + } + + // Combine access condition + Predicate accessPredicate = cb.or(epersonPredicate, groupPredicate); + predicates.add(accessPredicate); + + // Apply WHERE clause + cq.where(cb.and(predicates.toArray(new Predicate[0]))); + + // Execute + return list(context, cq, true, Collection.class, -1, -1); + } + @Override public List findCollectionsWithSubscribers(Context context) throws SQLException { - return list(createQuery(context, "SELECT DISTINCT c FROM Collection c JOIN Subscription s ON c.id = " + + return list(createQuery(context, "SELECT DISTINCT c FROM Collection c JOIN Subscription s ON c = " + "s.dSpaceObject")); } @@ -172,15 +277,26 @@ public int countRows(Context context) throws SQLException { @SuppressWarnings("unchecked") public List> getCollectionsWithBitstreamSizesTotal(Context context) throws SQLException { - String q = "select col as collection, sum(bit.sizeBytes) as totalBytes from Item i join i.collections col " + - "join i.bundles bun join bun.bitstreams bit group by col"; + String q = "select col.id, sum(bit.sizeBytes) as totalBytes from Item i join i.collections col " + + "join i.bundles bun join bun.bitstreams bit group by col.id"; Query query = createQuery(context, q); + CriteriaBuilder criteriaBuilder = getCriteriaBuilder(context); + List list = query.getResultList(); List> returnList = new ArrayList<>(list.size()); for (Object[] o : list) { - returnList.add(new AbstractMap.SimpleEntry<>((Collection) o[0], (Long) o[1])); + CriteriaQuery criteriaQuery = criteriaBuilder.createQuery(Collection.class); + Root collectionRoot = criteriaQuery.from(Collection.class); + criteriaQuery.select(collectionRoot).where(criteriaBuilder.equal(collectionRoot.get("id"), (UUID) o[0])); + Query collectionQuery = createQuery(context, criteriaQuery); + Collection collection = (Collection) collectionQuery.getSingleResult(); + if (collection != null) { + returnList.add(new AbstractMap.SimpleEntry<>(collection, (Long) o[1])); + } else { + log.warn("Unable to find Collection with UUID: {}", o[0]); + } } return returnList; } -} \ No newline at end of file +} diff --git a/dspace-api/src/main/java/org/dspace/content/dao/impl/EntityTypeDAOImpl.java b/dspace-api/src/main/java/org/dspace/content/dao/impl/EntityTypeDAOImpl.java index 489f4cd0667..957cbdbb157 100644 --- a/dspace-api/src/main/java/org/dspace/content/dao/impl/EntityTypeDAOImpl.java +++ b/dspace-api/src/main/java/org/dspace/content/dao/impl/EntityTypeDAOImpl.java @@ -53,7 +53,7 @@ public List getEntityTypesByNames(Context context, List name orderList.add(criteriaBuilder.desc(entityTypeRoot.get(EntityType_.label))); criteriaQuery.select(entityTypeRoot).orderBy(orderList); criteriaQuery.where(entityTypeRoot.get(EntityType_.LABEL).in(names)); - return list(context, criteriaQuery, false, EntityType.class, limit, offset); + return list(context, criteriaQuery, true, EntityType.class, limit, offset); } @Override diff --git a/dspace-api/src/main/java/org/dspace/content/dao/impl/RelationshipTypeDAOImpl.java b/dspace-api/src/main/java/org/dspace/content/dao/impl/RelationshipTypeDAOImpl.java index 7fff2a1f57d..2d31707e6eb 100644 --- a/dspace-api/src/main/java/org/dspace/content/dao/impl/RelationshipTypeDAOImpl.java +++ b/dspace-api/src/main/java/org/dspace/content/dao/impl/RelationshipTypeDAOImpl.java @@ -46,7 +46,7 @@ public RelationshipType findbyTypesAndTypeName(Context context, EntityType leftT criteriaBuilder.equal(relationshipTypeRoot.get(RelationshipType_.rightType), rightType), criteriaBuilder.equal(relationshipTypeRoot.get(RelationshipType_.leftwardType), leftwardType), criteriaBuilder.equal(relationshipTypeRoot.get(RelationshipType_.rightwardType), rightwardType))); - return uniqueResult(context, criteriaQuery, false, RelationshipType.class); + return uniqueResult(context, criteriaQuery, true, RelationshipType.class); } @Override @@ -96,7 +96,7 @@ public List findByEntityType(Context context, EntityType entit List orderList = new LinkedList<>(); orderList.add(criteriaBuilder.asc(relationshipTypeRoot.get(RelationshipType_.ID))); criteriaQuery.orderBy(orderList); - return list(context, criteriaQuery, false, RelationshipType.class, limit, offset); + return list(context, criteriaQuery, true, RelationshipType.class, limit, offset); } @Override @@ -122,7 +122,7 @@ public List findByEntityType(Context context, EntityType entit criteriaBuilder.equal(relationshipTypeRoot.get(RelationshipType_.rightType), entityType) ); } - return list(context, criteriaQuery, false, RelationshipType.class, limit, offset); + return list(context, criteriaQuery, true, RelationshipType.class, limit, offset); } @Override diff --git a/dspace-api/src/main/java/org/dspace/content/logic/condition/ReadableByGroupCondition.java b/dspace-api/src/main/java/org/dspace/content/logic/condition/ReadableByGroupCondition.java index 20138beb47e..e7b0bb7e046 100644 --- a/dspace-api/src/main/java/org/dspace/content/logic/condition/ReadableByGroupCondition.java +++ b/dspace-api/src/main/java/org/dspace/content/logic/condition/ReadableByGroupCondition.java @@ -49,7 +49,7 @@ public boolean getResult(Context context, Item item) throws LogicalStatementExce List policies = authorizeService .getPoliciesActionFilter(context, item, Constants.getActionID(action)); for (ResourcePolicy policy : policies) { - if (policy.getGroup().getName().equals(group)) { + if (policy.getGroup() != null && policy.getGroup().getName().equals(group)) { return true; } } diff --git a/dspace-api/src/main/java/org/dspace/content/packager/AbstractMETSDisseminator.java b/dspace-api/src/main/java/org/dspace/content/packager/AbstractMETSDisseminator.java index fd50ec8023e..2161a222e1f 100644 --- a/dspace-api/src/main/java/org/dspace/content/packager/AbstractMETSDisseminator.java +++ b/dspace-api/src/main/java/org/dspace/content/packager/AbstractMETSDisseminator.java @@ -456,17 +456,37 @@ protected void addBitstreamsToZip(Context context, DSpaceObject dso, // contents are unchanged ze.setTime(DEFAULT_MODIFIED_DATE); } - ze.setSize(auth ? bitstream.getSizeBytes() : 0); - zip.putNextEntry(ze); + + long bitstreamSize = 0; + // If user is authorized to read this bitstream, attempt to retrieve it. if (auth) { - InputStream input = bitstreamService.retrieve(context, bitstream); - Utils.copy(input, zip); - input.close(); + try (final InputStream bitstreamInput = bitstreamService.retrieve(context, bitstream)) { + // Save bitstream size into Zip entry & put entry in Zip file. + bitstreamSize = bitstream.getSizeBytes(); + ze.setSize(bitstreamSize); + zip.putNextEntry(ze); + + // Copy bitstream contents to Zip file + Utils.copy(bitstreamInput, zip); + } catch (Exception e) { + log.warn("Adding zero-length file for Bitstream, uuid={}." + + " Bitstream is unable to be retrieved from assetstore." + + " Error={}", bitstream.getID(), e.getMessage()); + } } else { - log.warn("Adding zero-length file for Bitstream, uuid=" - + String.valueOf(bitstream.getID()) - + ", not authorized for READ."); + log.warn("Adding zero-length file for Bitstream, uuid={}" + + ", not authorized for READ.", bitstream.getID()); + } + + // If bitstreamSize is still zero, that means either we didn't have READ privileges + // or the bitstream could not be retrieved from storage. Either way, write a zero-length + // file into our Zip entry in place of the bitstream. + if (bitstreamSize == 0) { + ze.setSize(0); + zip.putNextEntry(ze); } + + // Close our zip entry zip.closeEntry(); } else if (unauth != null && unauth.equalsIgnoreCase("skip")) { log.warn("Skipping Bitstream, uuid=" + String @@ -629,6 +649,13 @@ protected MdSec makeMdSec(Context context, DSpaceObject dso, Class mdSecClass, ByteArrayOutputStream disseminateOutput = new ByteArrayOutputStream(); sxwalk.disseminate(context, dso, disseminateOutput); disseminateOutput.close(); + + // If our disseminated output has zero size, exit immediately (i.e. return a null mdSec). + // Likely, the outputstream failed to be created, so we cannot include it in this package. + if (disseminateOutput.size() == 0) { + return null; + } + // Convert output to an inputstream, so we can write to manifest or Zip file ByteArrayInputStream crosswalkedStream = new ByteArrayInputStream( disseminateOutput.toByteArray()); diff --git a/dspace-api/src/main/java/org/dspace/content/packager/AbstractMETSIngester.java b/dspace-api/src/main/java/org/dspace/content/packager/AbstractMETSIngester.java index 925b2b5db3c..122f1399208 100644 --- a/dspace-api/src/main/java/org/dspace/content/packager/AbstractMETSIngester.java +++ b/dspace-api/src/main/java/org/dspace/content/packager/AbstractMETSIngester.java @@ -19,6 +19,7 @@ import java.util.zip.ZipFile; import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.io.input.NullInputStream; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.CloseableHttpClient; @@ -131,7 +132,7 @@ public abstract class AbstractMETSIngester extends AbstractPackageIngester { = DSpaceServicesFactory.getInstance().getConfigurationService(); - protected AuthorizeService authorizeService = AuthorizeServiceFactory.getInstance().getAuthorizeService(); + protected final AuthorizeService authorizeService = AuthorizeServiceFactory.getInstance().getAuthorizeService(); /** @@ -505,8 +506,11 @@ protected DSpaceObject ingestObject(Context context, DSpaceObject parent, // Finish creating the item. This actually assigns the handle, // and will either install item immediately or start a workflow, based on params PackageUtils.finishCreateItem(context, wsi, handle, params); + } else { + // We should have a workspace item during ingest, so this code is only here for safety. + // Update the object to make sure all changes are committed + PackageUtils.updateDSpaceObject(context, dso); } - } else if (type == Constants.COLLECTION || type == Constants.COMMUNITY) { // Add logo if one is referenced from manifest addContainerLogo(context, dso, manifest, pkgFile, params); @@ -520,6 +524,9 @@ protected DSpaceObject ingestObject(Context context, DSpaceObject parent, // (this allows subclasses to do some final validation / changes as // necessary) finishObject(context, dso, params); + + // Update the object to make sure all changes are committed + PackageUtils.updateDSpaceObject(context, dso); } else if (type == Constants.SITE) { // Do nothing by default -- Crosswalks will handle anything necessary to ingest at Site-level @@ -527,18 +534,15 @@ protected DSpaceObject ingestObject(Context context, DSpaceObject parent, // (this allows subclasses to do some final validation / changes as // necessary) finishObject(context, dso, params); + + // Update the object to make sure all changes are committed + PackageUtils.updateDSpaceObject(context, dso); } else { throw new PackageValidationException( "Unknown DSpace Object type in package, type=" + String.valueOf(type)); } - // -- Step 6 -- - // Finish things up! - - // Update the object to make sure all changes are committed - PackageUtils.updateDSpaceObject(context, dso); - return dso; } @@ -745,6 +749,14 @@ protected void addBitstreams(Context context, Item item, // externally, if it is an externally referenced file) InputStream fileStream = getFileInputStream(pkgFile, params, path); + // Before proceeding we must ensure we have a non-empty input stream + // NOTE: If getFileInputStream encounters a zero-sized file, then it returns NullInputStream + if (fileStream == null || fileStream instanceof NullInputStream) { + log.warn("Empty InputStream encountered for Bitstream with ID={} in zip file={}. " + + "Skipping adding this empty bitstream to Item={}", mfileID, pkgFile, item.getID()); + continue; + } + // retrieve bundle name from manifest String bundleName = METSManifest.getBundleName(mfile); @@ -767,15 +779,23 @@ protected void addBitstreams(Context context, Item item, bitstream.setSequenceID(Integer.parseInt(seqID)); } - // get bitstream policies before removing them in the `manifest.crosswalkBitstream` method - List bitstreamPolicies = authorizeService.getPolicies(context, bitstream); + // Get TYPE_SUBMISSION policies before removing them in the `manifest.crosswalkBitstream` method. + List bitstreamPolicies = + authorizeService.findPoliciesByDSOAndType(context, bitstream, ResourcePolicy.TYPE_SUBMISSION); // crosswalk this bitstream's administrative metadata located in // METS manifest (or referenced externally) manifest.crosswalkBitstream(context, params, bitstream, mfileID, mdRefCallback); - authorizeService.addPolicies(context, bitstreamPolicies, bitstream); + // Only add the saved TYPE_SUBMISSION policies if the crosswalk actually removed them to prevent duplicates. + if (!bitstreamPolicies.isEmpty()) { + List remainingSubmissionPolicies = + authorizeService.findPoliciesByDSOAndType(context, bitstream, ResourcePolicy.TYPE_SUBMISSION); + if (remainingSubmissionPolicies.isEmpty()) { + authorizeService.addPolicies(context, bitstreamPolicies, bitstream); + } + } // is this the primary bitstream? if (primaryID != null && mfileID.equals(primaryID)) { @@ -1313,7 +1333,7 @@ public String getObjectHandle(METSManifest manifest) * zip) * @param params Parameters passed to METSIngester * @param path the File path (either path in Zip package or a URL) - * @return the InputStream for the file + * @return the InputStream for the file, or NullInputStream if a zero-sized entry is encountered * @throws MetadataValidationException if validation error * @throws IOException if IO error */ @@ -1346,12 +1366,13 @@ protected static InputStream getFileInputStream(File pkgFile, // Retrieve the manifest file entry by name ZipEntry manifestEntry = zipPackage.getEntry(path); - // Get inputStream associated with this file - if (manifestEntry != null) { + if (manifestEntry.getSize() > 0) { + // Get inputStream associated with this file return zipPackage.getInputStream(manifestEntry); } else { - throw new MetadataValidationException("Manifest file references file '" - + path + "' not included in the zip."); + log.warn("Zero-sized file entry={} found in zip file={}. Returning empty InputStream.", + path, pkgFile); + return new NullInputStream(); } } } diff --git a/dspace-api/src/main/java/org/dspace/content/packager/RoleDisseminator.java b/dspace-api/src/main/java/org/dspace/content/packager/RoleDisseminator.java index 8fed8348bfd..41fa68c9fb1 100644 --- a/dspace-api/src/main/java/org/dspace/content/packager/RoleDisseminator.java +++ b/dspace-api/src/main/java/org/dspace/content/packager/RoleDisseminator.java @@ -313,8 +313,8 @@ protected void writeGroup(Context context, DSpaceObject relatedObject, Group gro for (EPerson member : group.getMembers()) { writer.writeEmptyElement(MEMBER); writer.writeAttribute(ID, String.valueOf(member.getID())); - if (null != member.getName()) { - writer.writeAttribute(NAME, member.getName()); + if (null != member.getEmail()) { + writer.writeAttribute(NAME, member.getEmail()); } } writer.writeEndElement(); diff --git a/dspace-api/src/main/java/org/dspace/content/service/BundleService.java b/dspace-api/src/main/java/org/dspace/content/service/BundleService.java index 10d6613b2a2..31e0d79f490 100644 --- a/dspace-api/src/main/java/org/dspace/content/service/BundleService.java +++ b/dspace-api/src/main/java/org/dspace/content/service/BundleService.java @@ -146,4 +146,12 @@ public void moveBitstreamToBundle(Context context, Bundle targetBundle, Bitstre public void setOrder(Context context, Bundle bundle, UUID bitstreamIds[]) throws AuthorizeException, SQLException; int countTotal(Context context) throws SQLException; + + /** + * Returns the count of bitstreams for the given bundle, performance optimized. + * + * @param context DSpace Context + * @param bundle the bitstream bundle + */ + int countBitstreams(Context context, Bundle bundle) throws SQLException; } diff --git a/dspace-api/src/main/java/org/dspace/content/service/CollectionService.java b/dspace-api/src/main/java/org/dspace/content/service/CollectionService.java index 170fed7f348..08258b87176 100644 --- a/dspace-api/src/main/java/org/dspace/content/service/CollectionService.java +++ b/dspace-api/src/main/java/org/dspace/content/service/CollectionService.java @@ -338,6 +338,18 @@ public void canEdit(Context context, Collection collection, boolean useInheritan public List findAuthorized(Context context, Community community, int actionID) throws java.sql.SQLException; + /** + * return an array of collections that user has a given permission on + * + * @param context DSpace Context + * @param community (optional) restrict search to a community, else null + * @param actions Listo of the of the action ADD, READ, ADMIN, etc. + * @return Collection [] of collections with matching permissions + * @throws SQLException if database error + */ + public List findAuthorized(Context context, Community community, List actions) + throws java.sql.SQLException; + /** * * @param context DSpace Context @@ -390,11 +402,11 @@ Group createDefaultReadGroup(Context context, Collection collection, String type * NOTE: for better performance, this method retrieves its results from an * index (cache) and does not query the database directly. * This means that results may be stale or outdated until https://github.com/DSpace/DSpace/issues/2853 is resolved" - * + * + * @param context DSpace Context * @param q limit the returned collection to those with metadata values matching the query terms. * The terms are used to make also a prefix query on SOLR so it can be used to implement * an autosuggest feature over the collection name - * @param context DSpace Context * @param community parent community * @param entityType limit the returned collection to those related to given entity type * @param offset the position of the first result to return @@ -403,7 +415,7 @@ Group createDefaultReadGroup(Context context, Collection collection, String type * @throws SQLException if something goes wrong * @throws SearchServiceException if search error */ - public List findCollectionsWithSubmit(String q, Context context, Community community, + public List findCollectionsWithSubmit(Context context, String q, Community community, String entityType, int offset, int limit) throws SQLException, SearchServiceException; /** @@ -421,11 +433,10 @@ public List findCollectionsWithSubmit(String q, Context context, Com * @param offset the position of the first result to return * @param limit paging limit * @return discovery search result objects - * @throws SQLException if something goes wrong * @throws SearchServiceException if search error */ public List findCollectionsWithSubmit(String q, Context context, Community community, - int offset, int limit) throws SQLException, SearchServiceException; + int offset, int limit) throws SearchServiceException; /** * Counts the number of Collection for which the current user has 'submit' privileges. @@ -433,17 +444,17 @@ public List findCollectionsWithSubmit(String q, Context context, Com * and does not query the database directly. * This means that results may be stale or outdated until * https://github.com/DSpace/DSpace/issues/2853 is resolved." - * + * + * @param context DSpace Context * @param q limit the returned collection to those with metadata values matching the query terms. * The terms are used to make also a prefix query on SOLR so it can be used to implement * an autosuggest feature over the collection name - * @param context DSpace Context * @param community parent community * @return total collections found * @throws SQLException if something goes wrong * @throws SearchServiceException if search error */ - public int countCollectionsWithSubmit(String q, Context context, Community community) + public int countCollectionsWithSubmit(Context context, String q, Community community) throws SQLException, SearchServiceException; /** @@ -452,18 +463,18 @@ public int countCollectionsWithSubmit(String q, Context context, Community commu * and does not query the database directly. * This means that results may be stale or outdated until * https://github.com/DSpace/DSpace/issues/2853 is resolved." - * + * + * @param context DSpace Context * @param q limit the returned collection to those with metadata values matching the query terms. * The terms are used to make also a prefix query on SOLR so it can be used to implement * an autosuggest feature over the collection name - * @param context DSpace Context * @param community parent community * @param entityType limit the returned collection to those related to given entity type * @return total collections found * @throws SQLException if something goes wrong * @throws SearchServiceException if search error */ - public int countCollectionsWithSubmit(String q, Context context, Community community, String entityType) + public int countCollectionsWithSubmit(Context context, String q, Community community, String entityType) throws SQLException, SearchServiceException; /** 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 51fbeb40e53..f4d1cb19490 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 @@ -890,23 +890,23 @@ public Iterator findByLastModifiedSince(Context context, Date last) /** * finds all items for which the current user has editing rights * @param context DSpace context object + * @param q search query * @param offset page offset * @param limit page size limit * @return list of items for which the current user has editing rights - * @throws SQLException * @throws SearchServiceException */ - public List findItemsWithEdit(Context context, int offset, int limit) - throws SQLException, SearchServiceException; + List findItemsWithEdit(Context context, String q, int offset, int limit) + throws SearchServiceException; /** * counts all items for which the current user has editing rights * @param context DSpace context object + * @param q search query * @return list of items for which the current user has editing rights - * @throws SQLException * @throws SearchServiceException */ - public int countItemsWithEdit(Context context) throws SQLException, SearchServiceException; + int countItemsWithEdit(Context context, String q) throws SearchServiceException; /** * Check if the supplied item is an inprogress submission 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 498c52c27f4..c36f1cacf9a 100644 --- a/dspace-api/src/main/java/org/dspace/core/AbstractHibernateDAO.java +++ b/dspace-api/src/main/java/org/dspace/core/AbstractHibernateDAO.java @@ -470,7 +470,20 @@ public List findByX(Context context, Class clazz, Map equals, for (Map.Entry entry : equals.entrySet()) { criteria.where(criteriaBuilder.equal(root.get(entry.getKey()), entry.getValue())); } + + criteria.orderBy(criteriaBuilder.asc(root.get("id"))); + return executeCriteriaQuery(context, criteria, cacheable, maxResults, offset); } + /** + * Create a Query object from a CriteriaQuery + * @param context current Context + * @param criteriaQuery CriteriaQuery built via CriteriaBuilder + * @return corresponding Query + * @throws SQLException if error occurs + */ + public Query createQuery(Context context, CriteriaQuery criteriaQuery) throws SQLException { + return this.getHibernateSession(context).createQuery(criteriaQuery); + } } diff --git a/dspace-api/src/main/java/org/dspace/core/LegacyPluginServiceImpl.java b/dspace-api/src/main/java/org/dspace/core/LegacyPluginServiceImpl.java index e92ea137f31..e7c092e75c9 100644 --- a/dspace-api/src/main/java/org/dspace/core/LegacyPluginServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/core/LegacyPluginServiceImpl.java @@ -17,6 +17,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -219,10 +220,10 @@ private Object getAnonymousPlugin(String classname) // Map of named plugin classes, [intfc,name] -> class // Also contains intfc -> "marker" to mark when interface has been loaded. - private final Map namedPluginClasses = new HashMap<>(); + private final Map namedPluginClasses = new ConcurrentHashMap<>(); // load and cache configuration data for the given interface. - private void configureNamedPlugin(String iname) + private synchronized void configureNamedPlugin(String iname) throws ClassNotFoundException { int found = 0; @@ -307,11 +308,10 @@ private int installNamedConfigs(String iname, String classname, String names[]) int found = 0; for (int i = 0; i < names.length; ++i) { String key = iname + SEP + names[i]; - if (namedPluginClasses.containsKey(key)) { + String existing = namedPluginClasses.putIfAbsent(key, classname); + if (existing != null) { log.error("Name collision in named plugin, implementation class=\"" + classname + "\", name=\"" + names[i] + "\""); - } else { - namedPluginClasses.put(key, classname); } log.debug("Got Named Plugin, intfc=" + iname + ", name=" + names[i] + ", class=" + classname); ++found; 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 02033184270..3c116df13bd 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 @@ -9,12 +9,15 @@ import java.io.IOException; import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; import java.util.ArrayList; import java.util.List; import org.apache.http.client.config.RequestConfig; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.utils.URIBuilder; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.logging.log4j.Logger; import org.dspace.app.client.DSpaceHttpClientFactory; @@ -141,7 +144,8 @@ protected boolean checkURL(String url, StringBuilder results) { protected int getResponseStatus(String url, int redirects) { RequestConfig config = RequestConfig.custom().setRedirectsEnabled(true).build(); try (CloseableHttpClient httpClient = DSpaceHttpClientFactory.getInstance().buildWithRequestConfig(config)) { - CloseableHttpResponse httpResponse = httpClient.execute(new HttpGet(url)); + URI uri = new URIBuilder(url).build(); + CloseableHttpResponse httpResponse = httpClient.execute(new HttpGet(uri)); int statusCode = httpResponse.getStatusLine().getStatusCode(); int maxRedirect = configurationService.getIntProperty("curate.checklinks.max-redirect", 0); if ((statusCode == HttpURLConnection.HTTP_MOVED_TEMP || statusCode == HttpURLConnection.HTTP_MOVED_PERM || @@ -153,6 +157,9 @@ protected int getResponseStatus(String url, int redirects) { } } return statusCode; + } catch (URISyntaxException e) { + log.error("Invalid URL: ", url, e); + return 0; } catch (IOException ioe) { // Must be a bad URL log.debug("Bad link: " + ioe.getMessage()); diff --git a/dspace-api/src/main/java/org/dspace/curate/AbstractCurationTask.java b/dspace-api/src/main/java/org/dspace/curate/AbstractCurationTask.java index fa16d273695..921ae7bdb7c 100644 --- a/dspace-api/src/main/java/org/dspace/curate/AbstractCurationTask.java +++ b/dspace-api/src/main/java/org/dspace/curate/AbstractCurationTask.java @@ -13,6 +13,10 @@ import java.util.List; import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.dspace.app.util.factory.UtilServiceFactory; +import org.dspace.app.util.service.DSpaceObjectUtils; import org.dspace.content.Collection; import org.dspace.content.Community; import org.dspace.content.DSpaceObject; @@ -42,6 +46,8 @@ public abstract class AbstractCurationTask implements CurationTask { protected ItemService itemService; protected HandleService handleService; protected ConfigurationService configurationService; + protected DSpaceObjectUtils dspaceObjectUtils; + private static final Logger log = LogManager.getLogger(); @Override public void init(Curator curator, String taskId) throws IOException { @@ -51,6 +57,7 @@ public void init(Curator curator, String taskId) throws IOException { itemService = ContentServiceFactory.getInstance().getItemService(); handleService = HandleServiceFactory.getInstance().getHandleService(); configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + dspaceObjectUtils = UtilServiceFactory.getInstance().getDSpaceObjectUtils(); } @Override @@ -153,7 +160,12 @@ protected void performItem(Item item) throws SQLException, IOException { @Override public int perform(Context ctx, String id) throws IOException { - DSpaceObject dso = dereference(ctx, id); + DSpaceObject dso = null; + try { + dso = dspaceObjectUtils.findDSpaceObject(ctx, id); + } catch (SQLException sqlE) { + throw new IOException(sqlE.getMessage(), sqlE); + } return (dso != null) ? perform(dso) : Curator.CURATE_FAIL; } diff --git a/dspace-api/src/main/java/org/dspace/curate/Curator.java b/dspace-api/src/main/java/org/dspace/curate/Curator.java index 4076fab5198..91f984b72e5 100644 --- a/dspace-api/src/main/java/org/dspace/curate/Curator.java +++ b/dspace-api/src/main/java/org/dspace/curate/Curator.java @@ -18,6 +18,8 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.dspace.app.util.factory.UtilServiceFactory; +import org.dspace.app.util.service.DSpaceObjectUtils; import org.dspace.content.Collection; import org.dspace.content.Community; import org.dspace.content.DSpaceObject; @@ -90,6 +92,7 @@ public static enum TxScope { protected TaskResolver resolver = new TaskResolver(); protected TxScope txScope = TxScope.OPEN; protected CommunityService communityService; + protected DSpaceObjectUtils dspaceObjectUtils; protected ItemService itemService; protected HandleService handleService; protected DSpaceRunnableHandler handler; @@ -109,6 +112,7 @@ public Curator(DSpaceRunnableHandler handler) { */ public Curator() { communityService = ContentServiceFactory.getInstance().getCommunityService(); + dspaceObjectUtils = UtilServiceFactory.getInstance().getDSpaceObjectUtils(); itemService = ContentServiceFactory.getInstance().getItemService(); handleService = HandleServiceFactory.getInstance().getHandleService(); resolver = new TaskResolver(); @@ -248,7 +252,7 @@ public void curate(Context c, String id) throws IOException { //Save the context on current execution thread curationCtx.set(c); - DSpaceObject dso = handleService.resolveToObject(c, id); + DSpaceObject dso = dspaceObjectUtils.findDSpaceObject(c,id); if (dso != null) { curate(dso); } else { diff --git a/dspace-api/src/main/java/org/dspace/discovery/DiscoverQuery.java b/dspace-api/src/main/java/org/dspace/discovery/DiscoverQuery.java index e133ad0ed17..185c768a057 100644 --- a/dspace-api/src/main/java/org/dspace/discovery/DiscoverQuery.java +++ b/dspace-api/src/main/java/org/dspace/discovery/DiscoverQuery.java @@ -72,6 +72,14 @@ public enum SORT_ORDER { private String discoveryConfigurationName; + /** + * The required authorizations user should have for the objects returned by the query. + * The READ authorization (Constants.READ) is always required and does not need to be added here. + */ + private List requiredAuthorization; + + private boolean inheritAuthorizations = true; + public DiscoverQuery() { //Initialize all our lists this.filterQueries = new ArrayList<>(); @@ -83,6 +91,7 @@ public DiscoverQuery() { this.hitHighlighting = new HashMap<>(); //Use a linked hashmap since sometimes insertion order might matter this.properties = new LinkedHashMap<>(); + this.requiredAuthorization = new ArrayList<>(); } @@ -411,4 +420,54 @@ public String getDiscoveryConfigurationName() { public void setDiscoveryConfigurationName(String discoveryConfigurationName) { this.discoveryConfigurationName = discoveryConfigurationName; } + + /** + * Return the required authorization user should have for the objects returned by this query + * + * @return the required authorizations + */ + public List getRequiredAuthorizations() { + return requiredAuthorization; + } + + /** + * Add a required authorization user should have for the objects returned by this query. + * The READ authorization (Constants.READ) is always required and does not need to be added here. + * + * @param action + * the required action + */ + public void addRequiredAuthorization(int action) { + this.requiredAuthorization.add(action); + } + + /** + * Remove a required authorization user should have for the objects returned by this query + * + * @param authorizationAction + * the required action + */ + public void removeRequiredAuthorization(int authorizationAction) { + this.requiredAuthorization.removeIf(action -> action == authorizationAction); + } + + /** + * Return whether authorizations should be inherited from parent objects + * + * @return true if authorizations should be inherited, false otherwise + */ + public boolean isInheritAuthorizationsEnabled() { + return inheritAuthorizations; + } + + /** + * Set whether authorizations should be inherited from parent objects + * + * @param inheritAuthorizations + * true if authorizations should be inherited, false otherwise + */ + public void setInheritAuthorizations(boolean inheritAuthorizations) { + this.inheritAuthorizations = inheritAuthorizations; + } + } diff --git a/dspace-api/src/main/java/org/dspace/discovery/SearchService.java b/dspace-api/src/main/java/org/dspace/discovery/SearchService.java index cb945648e7c..986b2d23de2 100644 --- a/dspace-api/src/main/java/org/dspace/discovery/SearchService.java +++ b/dspace-api/src/main/java/org/dspace/discovery/SearchService.java @@ -90,6 +90,8 @@ DiscoverFilterQuery toFilterQuery(Context context, String field, String operator List getRelatedItems(Context context, Item item, DiscoveryMoreLikeThisConfiguration moreLikeThisConfiguration); + String createLocationQueryForAdministrableDSOs(String epersonAndGroupClause); + /** * Method to create a Query that includes all * communities and collections a user may administrate. @@ -124,6 +126,15 @@ List getRelatedItems(Context context, Item item, */ String escapeQueryChars(String query); + /** + * Utility method to format an autocomplete query over a specific field. + * + * @param query to search for + * @param autocompleteField the field to use to autocomplete search, if null or empty no field is used + * @return the constructed solr query + */ + String formatAutoCompleteQuery(String query, String autocompleteField); + FacetYearRange getFacetYearRange(Context context, IndexableObject scope, DiscoverySearchFilterFacet facet, List filterQueries, DiscoverQuery parentQuery) throws SearchServiceException; diff --git a/dspace-api/src/main/java/org/dspace/discovery/SolrServiceImpl.java b/dspace-api/src/main/java/org/dspace/discovery/SolrServiceImpl.java index 024b1193a13..6eac6afda92 100644 --- a/dspace-api/src/main/java/org/dspace/discovery/SolrServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/discovery/SolrServiceImpl.java @@ -18,6 +18,7 @@ import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Arrays; import java.util.Calendar; import java.util.Collections; import java.util.Date; @@ -603,6 +604,58 @@ protected boolean requiresIndexing(String uniqueId, Date lastModified) return reindexItem || !inIndex; } + /** + * Retrieves from Solr the list of administrable communities and collections for the + * current user based on a clause containing the e-person and group IDs. + * Builds and returns the "location" query part for these DSO's. + * + * @param epersonAndGroupClause A Solr filter clause containing one or more IDs combined with OR, + * e.g. {@code "eUUIDe1 OR gUUIDg2 OR gUUIDg3 OR ..."}. + * + * @return An empty string if no administrable DSO exists, or a string in the form + * {@code "location:(mUUID1 OR lUUID2 ... )"} when there are administrable DSO's. + */ + @Override + public String createLocationQueryForAdministrableDSOs(String epersonAndGroupClause) { + StringBuilder locationQuery = new StringBuilder(); + try { + + SolrQuery solrQuery = new SolrQuery(); + + String query = "*:*"; + solrQuery.setQuery(query); + solrQuery.addField(SearchUtils.RESOURCE_ID_FIELD); + solrQuery.addField(SearchUtils.RESOURCE_TYPE_FIELD); + solrQuery.addFilterQuery("(" + SearchUtils.RESOURCE_TYPE_FIELD + ":" + IndexableCommunity.TYPE + " OR " + + SearchUtils.RESOURCE_TYPE_FIELD + ":" + IndexableCollection.TYPE + ")"); + solrQuery.addFilterQuery("admin:(" + epersonAndGroupClause + ")"); + solrQuery.setRows(Integer.MAX_VALUE); + + QueryResponse solrQueryResponse = solrSearchCore.getSolr().query(solrQuery, + solrSearchCore.REQUEST_METHOD); + if (solrQueryResponse != null) { + List containerUUIDs = new ArrayList<>(); + for (SolrDocument doc : solrQueryResponse.getResults()) { + String type = (String) doc.getFieldValue(SearchUtils.RESOURCE_TYPE_FIELD); + String uniqueID = (String) doc.getFieldValue(SearchUtils.RESOURCE_ID_FIELD); + if (IndexableCommunity.TYPE.equals(type)) { + containerUUIDs.add("m" + uniqueID); + } else if (IndexableCollection.TYPE.equals(type)) { + containerUUIDs.add("l" + uniqueID); + } + } + if (!containerUUIDs.isEmpty()) { + locationQuery.append("location:("); + locationQuery.append(String.join(" OR ", containerUUIDs)); + return locationQuery.append(")").toString(); + } + } + } catch (Exception e) { + log.error("Failed to retrieve administrable communities and collections from Solr:", e); + } + return ""; + } + @Override public String createLocationQueryForAdministrableItems(Context context) throws SQLException { @@ -976,8 +1029,20 @@ protected SolrQuery resolveToSolrQuery(Context context, DiscoverQuery discoveryQ if (0 < discoveryQuery.getHitHighlightingFields().size()) { solrQuery.setHighlight(true); solrQuery.add(HighlightParams.USE_PHRASE_HIGHLIGHTER, Boolean.TRUE.toString()); + boolean escapeHTML = configurationService.getBooleanProperty("discovery.highlights.escape-html", true); + String[] renderHTMLForFields = + configurationService.getArrayProperty("discovery.highlights.html-allowed-fields"); for (DiscoverHitHighlightingField highlightingField : discoveryQuery.getHitHighlightingFields()) { solrQuery.addHighlightField(highlightingField.getField() + "_hl"); + boolean allowHTMLInField = Arrays.stream(renderHTMLForFields) + .anyMatch(field -> highlightingField.getField().matches(field)); + if (!escapeHTML || allowHTMLInField) { + solrQuery.add("f." + highlightingField.getField() + "_hl." + HighlightParams.METHOD, "original"); + } else { + solrQuery.add("f." + highlightingField.getField() + "_hl." + HighlightParams.METHOD, "unified"); + solrQuery.add("f." + highlightingField.getField() + "_hl." + HighlightParams.ENCODER, "html"); + } + solrQuery.add("f." + highlightingField.getField() + "_hl." + HighlightParams.FRAGSIZE, String.valueOf(highlightingField.getMaxChars())); solrQuery.add("f." + highlightingField.getField() + "_hl." + HighlightParams.SNIPPETS, @@ -1636,6 +1701,27 @@ public String escapeQueryChars(String query) { return ClientUtils.escapeQueryChars(query); } + /** + * Utility method to format an autocomplete query over a specific field. Combines the escaped query with a + * wildcard search over the specified {@code autocompleteField}. This field is typically non-tokenized and + * allows recovering searches containing spaces as a single value. + * + * @param query the user input to search for + * @param autocompleteField non-tokenized field used for wildcard autocomplete + * @return the constructed Solr query, or the original query if blank + */ + @Override + public String formatAutoCompleteQuery(String query, String autocompleteField) { + if (StringUtils.isNotBlank(query)) { + StringBuilder buildQuery = new StringBuilder(); + String escapedQuery = escapeQueryChars(query); + buildQuery.append("(").append(escapedQuery).append(" OR ").append(autocompleteField).append(":*") + .append(escapedQuery).append("*").append(")"); + return buildQuery.toString(); + } + return query; + } + @Override public FacetYearRange getFacetYearRange(Context context, IndexableObject scope, DiscoverySearchFilterFacet facet, List filterQueries, diff --git a/dspace-api/src/main/java/org/dspace/discovery/SolrServicePrivateItemPlugin.java b/dspace-api/src/main/java/org/dspace/discovery/SolrServicePrivateItemPlugin.java index db543141e13..aab1176d5af 100644 --- a/dspace-api/src/main/java/org/dspace/discovery/SolrServicePrivateItemPlugin.java +++ b/dspace-api/src/main/java/org/dspace/discovery/SolrServicePrivateItemPlugin.java @@ -45,9 +45,8 @@ public void additionalSearchParameters(Context context, DiscoverQuery discoveryQ solrQuery.addFilterQuery("NOT(discoverable:false)"); return; } - if (!authorizeService.isCommunityAdmin(context) && !authorizeService.isCollectionAdmin(context)) { + if (!authorizeService.isComColAdmin(context)) { solrQuery.addFilterQuery("NOT(discoverable:false)"); - } } catch (SQLException ex) { log.error(LogHelper.getHeader(context, "Error looking up authorization rights of current user", diff --git a/dspace-api/src/main/java/org/dspace/discovery/SolrServiceResourceRestrictionPlugin.java b/dspace-api/src/main/java/org/dspace/discovery/SolrServiceResourceRestrictionPlugin.java index d19616a85e1..db5b03e24a3 100644 --- a/dspace-api/src/main/java/org/dspace/discovery/SolrServiceResourceRestrictionPlugin.java +++ b/dspace-api/src/main/java/org/dspace/discovery/SolrServiceResourceRestrictionPlugin.java @@ -18,12 +18,8 @@ import org.dspace.authorize.ResourcePolicy; import org.dspace.authorize.service.AuthorizeService; import org.dspace.authorize.service.ResourcePolicyService; -import org.dspace.content.Collection; -import org.dspace.content.Community; import org.dspace.content.DSpaceObject; import org.dspace.content.InProgressSubmission; -import org.dspace.content.Item; -import org.dspace.content.factory.ContentServiceFactory; import org.dspace.content.service.CollectionService; import org.dspace.content.service.CommunityService; import org.dspace.core.Constants; @@ -36,14 +32,14 @@ import org.dspace.eperson.EPerson; import org.dspace.eperson.Group; import org.dspace.eperson.service.GroupService; -import org.dspace.services.factory.DSpaceServicesFactory; import org.dspace.xmlworkflow.storedcomponents.ClaimedTask; import org.dspace.xmlworkflow.storedcomponents.PoolTask; import org.springframework.beans.factory.annotation.Autowired; /** * Restriction plugin that ensures that indexes all the resource policies. - * When a search is performed extra filter queries are added to retrieve only results to which the user has READ access + * When a search is performed extra filter queries are added to retrieve only results to which the user has the + * required authorization. * * @author Kevin Van de Velde (kevin at atmire dot com) * @author Mark Diggory (markd at atmire dot com) @@ -64,6 +60,8 @@ public class SolrServiceResourceRestrictionPlugin implements SolrServiceIndexPlu protected GroupService groupService; @Autowired(required = true) protected ResourcePolicyService resourcePolicyService; + @Autowired + protected SearchService searchService; @Override public void additionalIndex(Context context, IndexableObject idxObj, SolrInputDocument document) { @@ -83,50 +81,32 @@ public void additionalIndex(Context context, IndexableObject idxObj, SolrInputDo } if (dso != null) { try { - List policies = authorizeService.getPoliciesActionFilter(context, dso, Constants.READ); - for (ResourcePolicy resourcePolicy : policies) { - if (resourcePolicyService.isDateValid(resourcePolicy)) { - String fieldValue; - if (resourcePolicy.getGroup() != null) { - //We have a group add it to the value - fieldValue = "g" + resourcePolicy.getGroup().getID(); - } else { - //We have an eperson add it to the value - fieldValue = "e" + resourcePolicy.getEPerson().getID(); - - } - - document.addField("read", fieldValue); - } - - //remove the policy from the cache to save memory - context.uncacheEntity(resourcePolicy); - } - // also index ADMIN policies as ADMIN permissions provides READ access - // going up through the hierarchy for communities, collections and items - while (dso != null) { - if (dso instanceof Community || dso instanceof Collection || dso instanceof Item) { - List policiesAdmin = authorizeService - .getPoliciesActionFilter(context, dso, Constants.ADMIN); - for (ResourcePolicy resourcePolicy : policiesAdmin) { - if (resourcePolicyService.isDateValid(resourcePolicy)) { - String fieldValue; - if (resourcePolicy.getGroup() != null) { - // We have a group add it to the value - fieldValue = "g" + resourcePolicy.getGroup().getID(); - } else { - // We have an eperson add it to the value - fieldValue = "e" + resourcePolicy.getEPerson().getID(); - } - document.addField("read", fieldValue); - document.addField("admin", fieldValue); + // Index read, submit, edit and admin permissions + int[] actionsToIndex = new int[] { Constants.READ, Constants.WRITE, Constants.ADD, Constants.ADMIN }; + + for (int action : actionsToIndex) { + String indexedActionName = getIndexedActionName(action); + List policies = authorizeService.getPoliciesActionFilter(context, dso, action); + for (ResourcePolicy resourcePolicy : policies) { + if (resourcePolicyService.isDateValid(resourcePolicy)) { + String fieldValue; + // Avoid NPE in cases where the policy does not have group or eperson + if (resourcePolicy.getGroup() == null && resourcePolicy.getEPerson() == null) { + continue; } - - // remove the policy from the cache to save memory - context.uncacheEntity(resourcePolicy); + if (resourcePolicy.getGroup() != null) { + //We have a group add it to the value + fieldValue = "g" + resourcePolicy.getGroup().getID(); + } else { + //We have an eperson add it to the value + fieldValue = "e" + resourcePolicy.getEPerson().getID(); + } + document.addField(indexedActionName, fieldValue); } + + //remove the policy from the cache to save memory + context.uncacheEntity(resourcePolicy); } - dso = ContentServiceFactory.getInstance().getDSpaceObjectService(dso).getParentObject(context, dso); } } catch (SQLException e) { log.error(LogHelper.getHeader(context, "Error while indexing resource policies", @@ -140,36 +120,66 @@ public void additionalIndex(Context context, IndexableObject idxObj, SolrInputDo public void additionalSearchParameters(Context context, DiscoverQuery discoveryQuery, SolrQuery solrQuery) { try { if (!authorizeService.isAdmin(context)) { - StringBuilder resourceQuery = new StringBuilder(); - //Always add the anonymous group id to the query - Group anonymousGroup = groupService.findByName(context, Group.ANONYMOUS); - String anonGroupId = ""; - if (anonymousGroup != null) { - anonGroupId = anonymousGroup.getID().toString(); - } - resourceQuery.append("read:(g" + anonGroupId); + EPerson currentUser = context.getCurrentUser(); + StringBuilder epersonAndGroupClause = new StringBuilder(); if (currentUser != null) { - resourceQuery.append(" OR e").append(currentUser.getID()); + epersonAndGroupClause.append("e").append(currentUser.getID()); } - //Retrieve all the groups the current user is a member of ! Set groups = groupService.allMemberGroupsSet(context, currentUser); for (Group group : groups) { - resourceQuery.append(" OR g").append(group.getID()); + if (epersonAndGroupClause.length() > 0) { + epersonAndGroupClause.append(" OR g").append(group.getID()); + } else { + epersonAndGroupClause.append("g").append(group.getID()); + } } - resourceQuery.append(")"); + StringBuilder resourceQuery = new StringBuilder(); - String locations = DSpaceServicesFactory.getInstance() - .getServiceManager() - .getServiceByName(SearchService.class.getName(), - SearchService.class) - .createLocationQueryForAdministrableItems(context); + List actions = discoveryQuery.getRequiredAuthorizations(); + /* + * The `actions` list specifies the permissions required beyond the default "read" permission. + * It should not include "read" because checking for "read" is always implicit. + * + * The query is constructed as follows: + * - If no actions are provided, it checks only for "read" or "admin" permissions. + * - If "admin" is in the `actions` list, it checks only for admin permissions. + * - Otherwise, it checks for both "read" and the other specified actions. + * + * The resulting query follows this structure: (read AND action) OR admin. + */ + if (actions.isEmpty()) { + // If no actions are included, we only check for read permissions + resourceQuery.append("(read:(").append(epersonAndGroupClause).append("))").append( " OR ") + .append("admin:(").append(epersonAndGroupClause).append(")"); + } else if (actions.contains(Constants.ADMIN)) { + // If the actions array contains the admin action, we only check for admin permissions + resourceQuery.append("admin:(").append(epersonAndGroupClause).append(")"); + } else { + // If the actions array contains other actions, we check for read permissions and the actions passed + resourceQuery.append("(read:(").append(epersonAndGroupClause).append(")"); + for (int action : actions) { + String actionName = getIndexedActionName(action); + resourceQuery.append(" AND ").append(actionName).append(":(").append(epersonAndGroupClause) + .append(")"); + } + resourceQuery.append(")"); + resourceQuery.append(" OR ").append("admin:(") + .append(epersonAndGroupClause).append(")"); + } - if (StringUtils.isNotBlank(locations)) { - resourceQuery.append(" OR "); - resourceQuery.append(locations); + // Add to the query the locations the user has administrative rights on to cover the cases of + // inherited permissions only if the inherit authorizations flag is enabled + if (discoveryQuery.isInheritAuthorizationsEnabled()) { + String locations = searchService + .createLocationQueryForAdministrableDSOs(epersonAndGroupClause.toString()); + + if (StringUtils.isNotBlank(locations)) { + resourceQuery.append(" OR "); + resourceQuery.append(locations); + } } solrQuery.addFilterQuery(resourceQuery.toString()); @@ -178,4 +188,26 @@ public void additionalSearchParameters(Context context, DiscoverQuery discoveryQ log.error(LogHelper.getHeader(context, "Error while adding resource policy information to query", ""), e); } } + + /** + * Get the action name used for solr indexing for the given action id + * + * @param action action id + * @return solr action name used for indexing + */ + private String getIndexedActionName(int action) { + + switch (action) { + case Constants.READ: + return "read"; + case Constants.WRITE: + return "edit"; + case Constants.ADD: + return "submit"; + case Constants.ADMIN: + return "admin"; + default: + return Constants.actionText[action].toLowerCase(); + } + } } diff --git a/dspace-api/src/main/java/org/dspace/discovery/indexobject/CommunityIndexFactoryImpl.java b/dspace-api/src/main/java/org/dspace/discovery/indexobject/CommunityIndexFactoryImpl.java index e9281960183..9f4b558cce7 100644 --- a/dspace-api/src/main/java/org/dspace/discovery/indexobject/CommunityIndexFactoryImpl.java +++ b/dspace-api/src/main/java/org/dspace/discovery/indexobject/CommunityIndexFactoryImpl.java @@ -126,7 +126,7 @@ public List getLocations(Context context, IndexableCommunity indexableDS final Community target = indexableDSpaceObject.getIndexedObject(); List locations = new ArrayList<>(); // build list of community ids - List communities = target.getParentCommunities(); + List communities = communityService.getAllParents(context, target); // now put those into strings for (Community community : communities) { diff --git a/dspace-api/src/main/java/org/dspace/disseminate/CitationDocumentServiceImpl.java b/dspace-api/src/main/java/org/dspace/disseminate/CitationDocumentServiceImpl.java index 1aa31d4db9e..bf9f7b9d6a0 100644 --- a/dspace-api/src/main/java/org/dspace/disseminate/CitationDocumentServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/disseminate/CitationDocumentServiceImpl.java @@ -142,8 +142,8 @@ public void afterPropertiesSet() throws Exception { //Load enabled collections String[] citationEnabledCollections = configurationService - .getArrayProperty("citation-page.enabled_collections"); - citationEnabledCollectionsList = Arrays.asList(citationEnabledCollections); + .getArrayProperty("citation-page.enabled_collections"); + citationEnabledCollectionsList = new ArrayList(Arrays.asList(citationEnabledCollections)); //Load enabled communities, and add to collection-list String[] citationEnabledCommunities = configurationService diff --git a/dspace-api/src/main/java/org/dspace/embargo/DefaultEmbargoSetter.java b/dspace-api/src/main/java/org/dspace/embargo/DefaultEmbargoSetter.java index 7857a45eb8d..265ec213da6 100644 --- a/dspace-api/src/main/java/org/dspace/embargo/DefaultEmbargoSetter.java +++ b/dspace-api/src/main/java/org/dspace/embargo/DefaultEmbargoSetter.java @@ -94,7 +94,6 @@ public void setEmbargo(Context context, Item item) if (!(bnn.equals(Constants.LICENSE_BUNDLE_NAME) || bnn.equals(Constants.METADATA_BUNDLE_NAME) || bnn .equals(CreativeCommonsServiceImpl.CC_BUNDLE_NAME))) { //AuthorizeManager.removePoliciesActionFilter(context, bn, Constants.READ); - generatePolicies(context, liftDate.toDate(), null, bn, item.getOwningCollection()); for (Bitstream bs : bn.getBitstreams()) { //AuthorizeManager.removePoliciesActionFilter(context, bs, Constants.READ); generatePolicies(context, liftDate.toDate(), null, bs, item.getOwningCollection()); diff --git a/dspace-api/src/main/java/org/dspace/eperson/EPerson.java b/dspace-api/src/main/java/org/dspace/eperson/EPerson.java index 3244a25018f..c6de502b0fa 100644 --- a/dspace-api/src/main/java/org/dspace/eperson/EPerson.java +++ b/dspace-api/src/main/java/org/dspace/eperson/EPerson.java @@ -379,7 +379,7 @@ public int getType() { @Override public String getName() { - return getEmail(); + return this.getFullName(); } String getDigestAlgorithm() { diff --git a/dspace-api/src/main/java/org/dspace/eperson/EPersonCLITool.java b/dspace-api/src/main/java/org/dspace/eperson/EPersonCLITool.java index fbc16cba90e..9df6a2fe559 100644 --- a/dspace-api/src/main/java/org/dspace/eperson/EPersonCLITool.java +++ b/dspace-api/src/main/java/org/dspace/eperson/EPersonCLITool.java @@ -53,7 +53,7 @@ public class EPersonCLITool { private static final Option OPT_PHONE = new Option("t", "telephone", true, "telephone number, empty for none"); private static final Option OPT_LANGUAGE = new Option("l", "language", true, "the person's preferred language"); private static final Option OPT_REQUIRE_CERTIFICATE = new Option("c", "requireCertificate", true, - "if 'true', an X.509 certificate will be " + + "if 'true', a certificate will be " + "required for login"); private static final Option OPT_CAN_LOGIN = new Option("C", "canLogIn", true, "'true' if the user can log in"); diff --git a/dspace-api/src/main/java/org/dspace/eperson/Group.java b/dspace-api/src/main/java/org/dspace/eperson/Group.java index 67655e0e0aa..af7d14661d2 100644 --- a/dspace-api/src/main/java/org/dspace/eperson/Group.java +++ b/dspace-api/src/main/java/org/dspace/eperson/Group.java @@ -142,7 +142,7 @@ boolean contains(EPerson e) { return getMembers().contains(e); } - List getParentGroups() { + public List getParentGroups() { return parentGroups; } diff --git a/dspace-api/src/main/java/org/dspace/eperson/GroupServiceImpl.java b/dspace-api/src/main/java/org/dspace/eperson/GroupServiceImpl.java index 4cec4c9c0d9..44727d3e5fb 100644 --- a/dspace-api/src/main/java/org/dspace/eperson/GroupServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/eperson/GroupServiceImpl.java @@ -142,6 +142,8 @@ public void addMember(Context context, Group group, EPerson e) { context.addEvent( new Event(Event.ADD, Constants.GROUP, group.getID(), Constants.EPERSON, e.getID(), e.getEmail(), getIdentifiers(context, group))); + log.info(LogHelper.getHeader(context, "add_group_eperson", + "group_id=" + group.getID() + ", eperson_id=" + e.getID())); } @Override @@ -157,6 +159,8 @@ public void addMember(Context context, Group groupParent, Group groupChild) thro context.addEvent(new Event(Event.ADD, Constants.GROUP, groupParent.getID(), Constants.GROUP, groupChild.getID(), groupChild.getName(), getIdentifiers(context, groupParent))); + log.info(LogHelper.getHeader(context, "add_group_subgroup", + "group_id=" + groupParent.getID() + ", subgroup_id=" + groupChild.getID())); } /** @@ -214,6 +218,8 @@ public void removeMember(Context context, Group group, EPerson ePerson) throws S if (group.remove(ePerson)) { context.addEvent(new Event(Event.REMOVE, Constants.GROUP, group.getID(), Constants.EPERSON, ePerson.getID(), ePerson.getEmail(), getIdentifiers(context, group))); + log.info(LogHelper.getHeader(context, "remove_group_eperson", + "group_id=" + group.getID() + ", eperson_id=" + ePerson.getID())); } } @@ -242,6 +248,8 @@ public void removeMember(Context context, Group groupParent, Group childGroup) t context.addEvent( new Event(Event.REMOVE, Constants.GROUP, groupParent.getID(), Constants.GROUP, childGroup.getID(), childGroup.getName(), getIdentifiers(context, groupParent))); + log.info(LogHelper.getHeader(context, "remove_group_subgroup", + "group_id=" + groupParent.getID() + ", subgroup_id=" + childGroup.getID())); } } diff --git a/dspace-api/src/main/java/org/dspace/eperson/dao/impl/GroupDAOImpl.java b/dspace-api/src/main/java/org/dspace/eperson/dao/impl/GroupDAOImpl.java index 6aea9ecd8d6..40323d23e16 100644 --- a/dspace-api/src/main/java/org/dspace/eperson/dao/impl/GroupDAOImpl.java +++ b/dspace-api/src/main/java/org/dspace/eperson/dao/impl/GroupDAOImpl.java @@ -92,7 +92,7 @@ public List findAll(Context context, int pageSize, int offset) throws SQL @Override public List findByEPerson(Context context, EPerson ePerson) throws SQLException { Query query = createQuery(context, - "from Group where (from EPerson e where e.id = :eperson_id) in elements(epeople)"); + "select distinct g from Group g join g.epeople ep where ep.id = :eperson_id"); query.setParameter("eperson_id", ePerson.getID()); query.setHint("org.hibernate.cacheable", Boolean.TRUE); diff --git a/dspace-api/src/main/java/org/dspace/external/OrcidConnectionException.java b/dspace-api/src/main/java/org/dspace/external/OrcidConnectionException.java new file mode 100644 index 00000000000..3574045aab2 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/external/OrcidConnectionException.java @@ -0,0 +1,33 @@ +/** + * 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.external; + +/** + * Exception thrown when there are issues with ORCID service connections. + * + * @author Boychuk Mykhaylo (mykhaylo.boychuk@4science.com) + */ +public class OrcidConnectionException extends Exception { + + private final int statusCode; + + public OrcidConnectionException(String message, int statusCode) { + super(message); + this.statusCode = statusCode; + } + + public OrcidConnectionException(String message, int statusCode, Throwable cause) { + super(message, cause); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } + +} diff --git a/dspace-api/src/main/java/org/dspace/external/OrcidRestConnector.java b/dspace-api/src/main/java/org/dspace/external/OrcidRestConnector.java index aa16af7a524..3d61462cc35 100644 --- a/dspace-api/src/main/java/org/dspace/external/OrcidRestConnector.java +++ b/dspace-api/src/main/java/org/dspace/external/OrcidRestConnector.java @@ -9,10 +9,9 @@ import java.io.ByteArrayInputStream; import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.Scanner; import org.apache.commons.lang3.StringUtils; +import org.apache.http.HttpResponse; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.CloseableHttpClient; @@ -28,9 +27,6 @@ */ public class OrcidRestConnector { - /** - * log4j logger - */ private static final Logger log = LogManager.getLogger(OrcidRestConnector.class); private final String url; @@ -39,33 +35,33 @@ public OrcidRestConnector(String url) { this.url = url; } - public InputStream get(String path, String accessToken) { - CloseableHttpResponse getResponse = null; - InputStream result = null; - path = trimSlashes(path); - - String fullPath = url + '/' + path; + public InputStream get(String path, String accessToken) throws OrcidConnectionException { + String fullPath = url + '/' + trimSlashes(path); HttpGet httpGet = new HttpGet(fullPath); if (StringUtils.isNotBlank(accessToken)) { httpGet.addHeader("Content-Type", "application/vnd.orcid+xml"); httpGet.addHeader("Authorization","Bearer " + accessToken); } try (CloseableHttpClient httpClient = DSpaceHttpClientFactory.getInstance().build()) { - getResponse = httpClient.execute(httpGet); - try (InputStream responseStream = getResponse.getEntity().getContent()) { - // Read all the content of the response stream into a byte array to prevent TruncatedChunkException - byte[] content = responseStream.readAllBytes(); - result = new ByteArrayInputStream(content); + try (CloseableHttpResponse httpResponse = httpClient.execute(httpGet)) { + if (!isSuccessful(httpResponse)) { + var statusCode = getStatusCode(httpResponse); + var reason = httpResponse.getStatusLine().getReasonPhrase(); + var error = String.format("The request failed with:%d code, reason:%s ", statusCode, reason); + throw new OrcidConnectionException(error, statusCode); + } + try (InputStream responseStream = httpResponse.getEntity().getContent()) { + // Read all the content of the response stream into a byte array to prevent TruncatedChunkException + byte[] content = responseStream.readAllBytes(); + return new ByteArrayInputStream(content); + } } + } catch (OrcidConnectionException e) { + throw e; } catch (Exception e) { - getGotError(e, fullPath); + log.error("Error in rest connector for path: " + fullPath, e); + throw new OrcidConnectionException("Failed to execute ORCID request: " + fullPath, 0, e); } - - return result; - } - - protected void getGotError(Exception e, String fullPath) { - log.error("Error in rest connector for path: " + fullPath, e); } public static String trimSlashes(String path) { @@ -78,8 +74,13 @@ public static String trimSlashes(String path) { return path; } - public static String convertStreamToString(InputStream is) { - Scanner s = new Scanner(is, StandardCharsets.UTF_8).useDelimiter("\\A"); - return s.hasNext() ? s.next() : ""; + private boolean isSuccessful(HttpResponse response) { + int statusCode = getStatusCode(response); + return statusCode >= 200 || statusCode <= 299; } -} + + private int getStatusCode(HttpResponse response) { + return response.getStatusLine().getStatusCode(); + } + +} \ No newline at end of file diff --git a/dspace-api/src/main/java/org/dspace/external/provider/impl/OrcidV3AuthorDataProvider.java b/dspace-api/src/main/java/org/dspace/external/provider/impl/OrcidV3AuthorDataProvider.java index a9e10f92948..fe2fdfa9538 100644 --- a/dspace-api/src/main/java/org/dspace/external/provider/impl/OrcidV3AuthorDataProvider.java +++ b/dspace-api/src/main/java/org/dspace/external/provider/impl/OrcidV3AuthorDataProvider.java @@ -21,6 +21,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.dspace.content.dto.MetadataValueDTO; +import org.dspace.external.OrcidConnectionException; import org.dspace.external.OrcidRestConnector; import org.dspace.external.model.ExternalDataObject; import org.dspace.external.provider.AbstractExternalDataProvider; @@ -89,6 +90,9 @@ public void init() throws IOException { public void initializeAccessToken() { // If we have reaches max retries or the access token is already set, return immediately if (maxClientRetries <= 0 || StringUtils.isNotBlank(accessToken)) { + if (maxClientRetries <= 0) { + log.warn("Maximum retry attempts reached for ORCID token retrieval"); + } return; } try { @@ -168,8 +172,14 @@ public Person getBio(String id) { return null; } initializeAccessToken(); - InputStream bioDocument = orcidRestConnector.get(id + ((id.endsWith("/person")) ? "" : "/person"), accessToken); - return converter.convertSinglePerson(bioDocument); + try { + InputStream bioDocument = orcidRestConnector.get(id + ((id.endsWith("/person")) ? "" : "/person"), + accessToken); + return converter.convertSinglePerson(bioDocument); + } catch (OrcidConnectionException e) { + log.error("Error retrieving ORCID bio for ID=" + id, e); + return null; + } } /** @@ -200,21 +210,26 @@ public List searchExternalDataObjects(String query, int star + "&start=" + start + "&rows=" + limit; log.debug("queryBio searchPath=" + searchPath + " accessToken=" + accessToken); - InputStream bioDocument = orcidRestConnector.get(searchPath, accessToken); - List results = converter.convert(bioDocument); - List bios = new LinkedList<>(); - for (Result result : results) { - OrcidIdentifier orcidIdentifier = result.getOrcidIdentifier(); - if (orcidIdentifier != null) { - log.debug("Found OrcidId=" + orcidIdentifier.getPath()); - String orcid = orcidIdentifier.getPath(); - Person bio = getBio(orcid); - if (bio != null) { - bios.add(bio); + try { + InputStream bioDocument = orcidRestConnector.get(searchPath, accessToken); + List results = converter.convert(bioDocument); + List bios = new LinkedList<>(); + for (Result result : results) { + OrcidIdentifier orcidIdentifier = result.getOrcidIdentifier(); + if (orcidIdentifier != null) { + log.debug("Found OrcidId=" + orcidIdentifier.getPath()); + String orcid = orcidIdentifier.getPath(); + Person bio = getBio(orcid); + if (bio != null) { + bios.add(bio); + } } } + return bios.stream().map(bio -> convertToExternalDataObject(bio)).collect(Collectors.toList()); + } catch (OrcidConnectionException e) { + log.error("Error searching ORCID for query=" + query, e); + return Collections.emptyList(); } - return bios.stream().map(bio -> convertToExternalDataObject(bio)).collect(Collectors.toList()); } @Override @@ -233,8 +248,13 @@ public int getNumberOfResults(String query) { + "&start=" + 0 + "&rows=" + 0; log.debug("queryBio searchPath=" + searchPath + " accessToken=" + accessToken); - InputStream bioDocument = orcidRestConnector.get(searchPath, accessToken); - return Math.min(converter.getNumberOfResultsFromXml(bioDocument), MAX_INDEX); + try { + InputStream bioDocument = orcidRestConnector.get(searchPath, accessToken); + return Math.min(converter.getNumberOfResultsFromXml(bioDocument), MAX_INDEX); + } catch (OrcidConnectionException e) { + log.error("Error getting number of results from ORCID for query=" + query, e); + return 0; + } } @@ -296,4 +316,4 @@ public void setOrcidRestConnector(OrcidRestConnector orcidRestConnector) { this.orcidRestConnector = orcidRestConnector; } -} +} \ No newline at end of file diff --git a/dspace-api/src/main/java/org/dspace/health/UserCheck.java b/dspace-api/src/main/java/org/dspace/health/UserCheck.java index 17a81ce3b26..7f177d3be74 100644 --- a/dspace-api/src/main/java/org/dspace/health/UserCheck.java +++ b/dspace-api/src/main/java/org/dspace/health/UserCheck.java @@ -56,23 +56,23 @@ public String run(ReportInfo ri) { info.put("Self registered", 0); for (EPerson e : epersons) { - if (e.getEmail() != null && e.getEmail().length() > 0) { + if (e.getEmail() != null && !e.getEmail().isEmpty()) { info.put(HAVE_EMAIL, info.get(HAVE_EMAIL) + 1); } if (e.canLogIn()) { info.put("Can log in (password)", info.get("Can log in (password)") + 1); } - if (e.getFirstName() != null && e.getFirstName().length() > 0) { + if (e.getFirstName() != null && !e.getFirstName().isEmpty()) { info.put("Have 1st name", info.get("Have 1st name") + 1); } - if (e.getLastName() != null && e.getLastName().length() > 0) { + if (e.getLastName() != null && !e.getLastName().isEmpty()) { info.put("Have 2nd name", info.get("Have 2nd name") + 1); } - if (e.getLanguage() != null && e.getLanguage().length() > 0) { + if (e.getLanguage() != null && !e.getLanguage().isEmpty()) { info.put("Have lang", info.get("Have lang") + 1); } - if (e.getNetid() != null && e.getNetid().length() > 0) { + if (e.getNetid() != null && !e.getNetid().isEmpty()) { info.put("Have netid", info.get("Have netid") + 1); } if (e.getSelfRegistered()) { diff --git a/dspace-api/src/main/java/org/dspace/importer/external/crossref/CrossRefAbstractProcessor.java b/dspace-api/src/main/java/org/dspace/importer/external/crossref/CrossRefAbstractProcessor.java index 99f1ee37a54..d54c70c3198 100644 --- a/dspace-api/src/main/java/org/dspace/importer/external/crossref/CrossRefAbstractProcessor.java +++ b/dspace-api/src/main/java/org/dspace/importer/external/crossref/CrossRefAbstractProcessor.java @@ -95,6 +95,14 @@ private String prettifyAbstract(String abstractValue) { sb.append("\n"); } sb.append("\n"); + } else if (StringUtils.equals(nodeName, "jats:p")) { + NodeList secElements = childElement.getChildNodes(); + for (int j = 0; j < secElements.getLength(); j++) { + Node secChildElement = secElements.item(j); + sb.append(secChildElement.getTextContent()); + sb.append("\n"); + } + sb.append("\n"); } } diff --git a/dspace-api/src/main/java/org/dspace/importer/external/metadatamapping/contributor/SimpleJsonPathMetadataContributor.java b/dspace-api/src/main/java/org/dspace/importer/external/metadatamapping/contributor/SimpleJsonPathMetadataContributor.java index 590fc63283b..db3ba16dc42 100644 --- a/dspace-api/src/main/java/org/dspace/importer/external/metadatamapping/contributor/SimpleJsonPathMetadataContributor.java +++ b/dspace-api/src/main/java/org/dspace/importer/external/metadatamapping/contributor/SimpleJsonPathMetadataContributor.java @@ -146,6 +146,9 @@ public Collection contributeMetadata(String fullJson) { } } for (String value : metadataValue) { + if (StringUtils.isBlank(value)) { + continue; + } MetadatumDTO metadatumDto = new MetadatumDTO(); metadatumDto.setValue(value); metadatumDto.setElement(field.getElement()); diff --git a/dspace-api/src/main/java/org/dspace/importer/external/pubmed/service/PubmedImportMetadataSourceServiceImpl.java b/dspace-api/src/main/java/org/dspace/importer/external/pubmed/service/PubmedImportMetadataSourceServiceImpl.java index c870161bf9b..dc995496939 100644 --- a/dspace-api/src/main/java/org/dspace/importer/external/pubmed/service/PubmedImportMetadataSourceServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/importer/external/pubmed/service/PubmedImportMetadataSourceServiceImpl.java @@ -55,6 +55,7 @@ public class PubmedImportMetadataSourceServiceImpl extends AbstractImportMetadat private String urlFetch; private String urlSearch; + private String apiKey; private int attempt = 3; @@ -210,6 +211,9 @@ public GetNbRecords(Query query) { @Override public Integer call() throws Exception { URIBuilder uriBuilder = new URIBuilder(urlSearch); + if (StringUtils.isNotBlank(apiKey)) { + uriBuilder.addParameter("api_key", apiKey); + } uriBuilder.addParameter("db", "pubmed"); uriBuilder.addParameter("term", query.getParameterAsClass("query", String.class)); Map> params = new HashMap>(); @@ -286,6 +290,9 @@ public Collection call() throws Exception { List records = new LinkedList(); URIBuilder uriBuilder = new URIBuilder(urlSearch); + if (StringUtils.isNotBlank(apiKey)) { + uriBuilder.addParameter("api_key", apiKey); + } uriBuilder.addParameter("db", "pubmed"); uriBuilder.addParameter("retstart", start.toString()); uriBuilder.addParameter("retmax", count.toString()); @@ -316,6 +323,9 @@ public Collection call() throws Exception { String webEnv = getSingleElementValue(response, "WebEnv"); URIBuilder uriBuilder2 = new URIBuilder(urlFetch); + if (StringUtils.isNotBlank(apiKey)) { + uriBuilder2.addParameter("api_key", apiKey); + } uriBuilder2.addParameter("db", "pubmed"); uriBuilder2.addParameter("retstart", start.toString()); uriBuilder2.addParameter("retmax", count.toString()); @@ -388,6 +398,9 @@ public GetRecord(Query q) { public ImportRecord call() throws Exception { URIBuilder uriBuilder = new URIBuilder(urlFetch); + if (StringUtils.isNotBlank(apiKey)) { + uriBuilder.addParameter("api_key", apiKey); + } uriBuilder.addParameter("db", "pubmed"); uriBuilder.addParameter("retmode", "xml"); uriBuilder.addParameter("id", query.getParameterAsClass("id", String.class)); @@ -428,6 +441,9 @@ public FindMatchingRecords(Query q) { public Collection call() throws Exception { URIBuilder uriBuilder = new URIBuilder(urlSearch); + if (StringUtils.isNotBlank(apiKey)) { + uriBuilder.addParameter("api_key", apiKey); + } uriBuilder.addParameter("db", "pubmed"); uriBuilder.addParameter("usehistory", "y"); uriBuilder.addParameter("term", query.getParameterAsClass("term", String.class)); @@ -457,6 +473,9 @@ public Collection call() throws Exception { String queryKey = getSingleElementValue(response, "QueryKey"); URIBuilder uriBuilder2 = new URIBuilder(urlFetch); + if (StringUtils.isNotBlank(apiKey)) { + uriBuilder.addParameter("api_key", apiKey); + } uriBuilder2.addParameter("db", "pubmed"); uriBuilder2.addParameter("retmode", "xml"); uriBuilder2.addParameter("WebEnv", webEnv); @@ -532,4 +551,8 @@ public void setUrlSearch(String urlSearch) { this.urlSearch = urlSearch; } + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + } diff --git a/dspace-api/src/main/java/org/dspace/orcid/model/OrcidWorkFieldMapping.java b/dspace-api/src/main/java/org/dspace/orcid/model/OrcidWorkFieldMapping.java index 781a9dcbd90..faefe798e92 100644 --- a/dspace-api/src/main/java/org/dspace/orcid/model/OrcidWorkFieldMapping.java +++ b/dspace-api/src/main/java/org/dspace/orcid/model/OrcidWorkFieldMapping.java @@ -39,6 +39,7 @@ public class OrcidWorkFieldMapping { * The metadata fields related to the work external identifiers. */ private Map externalIdentifierFields = new HashMap<>(); + private Map> externalIdentifierPartOfMap = new HashMap<>(); /** * The metadata field related to the work publication date. @@ -129,6 +130,15 @@ public void setExternalIdentifierFields(String externalIdentifierFields) { this.externalIdentifierFields = parseConfigurations(externalIdentifierFields); } + public Map> getExternalIdentifierPartOfMap() { + return this.externalIdentifierPartOfMap; + } + + public void setExternalIdentifierPartOfMap( + HashMap> externalIdentifierPartOfMap) { + this.externalIdentifierPartOfMap = externalIdentifierPartOfMap; + } + public String getPublicationDateField() { return publicationDateField; } diff --git a/dspace-api/src/main/java/org/dspace/orcid/model/factory/OrcidFactoryUtils.java b/dspace-api/src/main/java/org/dspace/orcid/model/factory/OrcidFactoryUtils.java index ce68ab47c26..f08aff74058 100644 --- a/dspace-api/src/main/java/org/dspace/orcid/model/factory/OrcidFactoryUtils.java +++ b/dspace-api/src/main/java/org/dspace/orcid/model/factory/OrcidFactoryUtils.java @@ -7,21 +7,29 @@ */ package org.dspace.orcid.model.factory; -import java.io.BufferedReader; +import static java.nio.charset.StandardCharsets.UTF_8; + import java.io.IOException; import java.io.InputStream; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import org.apache.commons.lang3.StringUtils; import org.apache.http.HttpResponse; +import org.apache.http.NameValuePair; +import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.HttpPost; import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.message.BasicNameValuePair; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.dspace.app.client.DSpaceHttpClientFactory; import org.json.JSONObject; +import org.json.JSONTokener; /** * Utility class for Orcid factory classes. This is used to parse the @@ -29,13 +37,12 @@ * contributors and external ids configuration). * * @author Luca Giamminonni (luca.giamminonni at 4science.it) - * */ public final class OrcidFactoryUtils { - private OrcidFactoryUtils() { + private static final Logger log = LogManager.getLogger(OrcidFactoryUtils.class); - } + private OrcidFactoryUtils() { } /** * Parse the given configurations value and returns a map with metadata fields @@ -46,7 +53,7 @@ private OrcidFactoryUtils() { * @return the configurations parsing result as map */ public static Map parseConfigurations(String configurations) { - Map configurationMap = new HashMap(); + Map configurationMap = new HashMap<>(); if (StringUtils.isBlank(configurations)) { return configurationMap; } @@ -55,7 +62,6 @@ public static Map parseConfigurations(String configurations) { String[] configurationSections = parseConfiguration(configuration); configurationMap.put(configurationSections[0], configurationSections[1]); } - return configurationMap; } @@ -87,37 +93,65 @@ private static String[] parseConfiguration(String configuration) { */ public static Optional retrieveAccessToken(String clientId, String clientSecret, String oauthUrl) throws IOException { - if (StringUtils.isNotBlank(clientSecret) && StringUtils.isNotBlank(clientId) - && StringUtils.isNotBlank(oauthUrl)) { - String authenticationParameters = "?client_id=" + clientId + - "&client_secret=" + clientSecret + - "&scope=/read-public&grant_type=client_credentials"; - HttpPost httpPost = new HttpPost(oauthUrl + authenticationParameters); - httpPost.addHeader("Accept", "application/json"); - httpPost.addHeader("Content-Type", "application/x-www-form-urlencoded"); - - HttpResponse response; - try (CloseableHttpClient httpClient = DSpaceHttpClientFactory.getInstance().build()) { - response = httpClient.execute(httpPost); + if (StringUtils.isBlank(clientSecret) || StringUtils.isBlank(clientId) || StringUtils.isBlank(oauthUrl)) { + String missingParams = (StringUtils.isBlank(clientId) ? "clientId " : "") + + (StringUtils.isBlank(clientSecret) ? "clientSecret " : "") + + (StringUtils.isBlank(oauthUrl) ? "oauthUrl" : ""); + log.error("Cannot retrieve ORCID access token: missing required parameters:{} ", missingParams.trim()); + return Optional.empty(); + } + + HttpPost httpPost = new HttpPost(oauthUrl); + + String auth = clientId + ":" + clientSecret; + String encodedAuth = Base64.getEncoder().encodeToString(auth.getBytes(UTF_8)); + addHeaders(httpPost, encodedAuth); + + List params = new ArrayList<>(); + params.add(new BasicNameValuePair("grant_type", "client_credentials")); + params.add(new BasicNameValuePair("scope", "/read-public")); + httpPost.setEntity(new UrlEncodedFormEntity(params, UTF_8)); + + try (CloseableHttpClient httpClient = DSpaceHttpClientFactory.getInstance().build()) { + log.debug("Sending ORCID token request to {}", oauthUrl); + HttpResponse response = httpClient.execute(httpPost); + if (!isSuccessful(response)) { + log.error("Failed to retrieve ORCID access token"); + return Optional.empty(); } - JSONObject responseObject = null; - if (response != null && response.getStatusLine().getStatusCode() == 200) { - try (InputStream is = response.getEntity().getContent(); - BufferedReader streamReader = new BufferedReader(new InputStreamReader(is, - StandardCharsets.UTF_8))) { - String inputStr; - while ((inputStr = streamReader.readLine()) != null && responseObject == null) { - if (inputStr.startsWith("{") && inputStr.endsWith("}") && inputStr.contains("access_token")) { - responseObject = new JSONObject(inputStr); - } - } + // Parsing JSON response + try (InputStream is = response.getEntity().getContent()) { + JSONObject responseObject = new JSONObject(new JSONTokener(is)); + if (responseObject.has("access_token")) { + String token = responseObject.getString("access_token"); + log.debug("Successfully retrieved ORCID access token"); + return Optional.of(token); + } else { + log.error("ORCID response missing access_token field:{} ", responseObject); + return Optional.empty(); } } - if (responseObject != null && responseObject.has("access_token")) { - return Optional.of((String) responseObject.get("access_token")); - } } - // Return empty by default - return Optional.empty(); } + + private static void addHeaders(HttpPost httpPost, String encodedAuth) { + httpPost.addHeader("Authorization", "Basic " + encodedAuth); + httpPost.addHeader("Accept", "application/json"); + httpPost.addHeader("Content-Type", "application/x-www-form-urlencoded"); + } + + private static boolean isSuccessful(HttpResponse response) { + if (response == null) { + log.error("ORCID API request failed: null response received"); + return false; + } + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != 200) { + var errorMsg = "ORCID API request failed with status code {}: {}"; + log.error(errorMsg, statusCode, response.getStatusLine().getReasonPhrase()); + return false; + } + return true; + } + } diff --git a/dspace-api/src/main/java/org/dspace/orcid/model/factory/impl/OrcidWorkFactory.java b/dspace-api/src/main/java/org/dspace/orcid/model/factory/impl/OrcidWorkFactory.java index 53b46d8256d..890dd0c6ed9 100644 --- a/dspace-api/src/main/java/org/dspace/orcid/model/factory/impl/OrcidWorkFactory.java +++ b/dspace-api/src/main/java/org/dspace/orcid/model/factory/impl/OrcidWorkFactory.java @@ -9,6 +9,7 @@ import static org.apache.commons.lang3.StringUtils.isBlank; import static org.apache.commons.lang3.StringUtils.isNotBlank; +import static org.orcid.jaxb.model.common.Relationship.PART_OF; import static org.orcid.jaxb.model.common.Relationship.SELF; import java.util.ArrayList; @@ -73,12 +74,12 @@ public OrcidEntityType getEntityType() { @Override public Activity createOrcidObject(Context context, Item item) { Work work = new Work(); + work.setWorkType(getWorkType(context, item)); work.setJournalTitle(getJournalTitle(context, item)); work.setWorkContributors(getWorkContributors(context, item)); work.setWorkTitle(getWorkTitle(context, item)); work.setPublicationDate(getPublicationDate(context, item)); - work.setWorkExternalIdentifiers(getWorkExternalIds(context, item)); - work.setWorkType(getWorkType(context, item)); + work.setWorkExternalIdentifiers(getWorkExternalIds(context, item, work)); work.setShortDescription(getShortDescription(context, item)); work.setLanguageCode(getLanguageCode(context, item)); work.setUrl(getUrl(context, item)); @@ -149,63 +150,71 @@ private PublicationDate getPublicationDate(Context context, Item item) { } /** - * Creates an instance of ExternalIDs from the metadata values of the given - * item, using the orcid.mapping.funding.external-ids configuration. + * Returns a list of external work IDs constructed in the org.orcid.jaxb + * ExternalIDs object */ - private ExternalIDs getWorkExternalIds(Context context, Item item) { - ExternalIDs externalIdentifiers = new ExternalIDs(); - externalIdentifiers.getExternalIdentifier().addAll(getWorkSelfExternalIds(context, item)); - return externalIdentifiers; + private ExternalIDs getWorkExternalIds(Context context, Item item, Work work) { + ExternalIDs externalIDs = new ExternalIDs(); + externalIDs.getExternalIdentifier().addAll(getWorkExternalIdList(context, item, work)); + return externalIDs; } /** * Creates a list of ExternalID, one for orcid.mapping.funding.external-ids - * value, taking the values from the given item. + * value, taking the values from the given item and work type. */ - private List getWorkSelfExternalIds(Context context, Item item) { + private List getWorkExternalIdList(Context context, Item item, Work work) { - List selfExternalIds = new ArrayList(); + List externalIds = new ArrayList<>(); Map externalIdentifierFields = fieldMapping.getExternalIdentifierFields(); if (externalIdentifierFields.containsKey(SIMPLE_HANDLE_PLACEHOLDER)) { String handleType = externalIdentifierFields.get(SIMPLE_HANDLE_PLACEHOLDER); - selfExternalIds.add(getExternalId(handleType, item.getHandle(), SELF)); + ExternalID handle = new ExternalID(); + handle.setType(handleType); + handle.setValue(item.getHandle()); + handle.setRelationship(SELF); + externalIds.add(handle); } + // Resolve work type, used to determine identifier relationship type + // For version / funding relationships, we might want to use more complex + // business rules than just "work and id type" + final String workType = (work != null && work.getWorkType() != null) ? + work.getWorkType().value() : WorkType.OTHER.value(); getMetadataValues(context, item, externalIdentifierFields.keySet()).stream() - .map(this::getSelfExternalId) - .forEach(selfExternalIds::add); + .map(metadataValue -> this.getExternalId(metadataValue, workType)) + .forEach(externalIds::add); - return selfExternalIds; - } - - /** - * Creates an instance of ExternalID taking the value from the given - * metadataValue. The type of the ExternalID is calculated using the - * orcid.mapping.funding.external-ids configuration. The relationship of the - * ExternalID is SELF. - */ - private ExternalID getSelfExternalId(MetadataValue metadataValue) { - Map externalIdentifierFields = fieldMapping.getExternalIdentifierFields(); - String metadataField = metadataValue.getMetadataField().toString('.'); - return getExternalId(externalIdentifierFields.get(metadataField), metadataValue.getValue(), SELF); + return externalIds; } /** * Creates an instance of ExternalID with the given type, value and * relationship. */ - private ExternalID getExternalId(String type, String value, Relationship relationship) { + private ExternalID getExternalId(MetadataValue metadataValue, String workType) { + Map externalIdentifierFields = fieldMapping.getExternalIdentifierFields(); + Map> externalIdentifierPartOfMap = fieldMapping.getExternalIdentifierPartOfMap(); + String metadataField = metadataValue.getMetadataField().toString('.'); + String identifierType = externalIdentifierFields.get(metadataField); + // Default relationship type is SELF, configuration can + // override to PART_OF based on identifier and work type + Relationship relationship = SELF; + if (externalIdentifierPartOfMap.containsKey(identifierType) + && externalIdentifierPartOfMap.get(identifierType).contains(workType)) { + relationship = PART_OF; + } ExternalID externalID = new ExternalID(); - externalID.setType(type); - externalID.setValue(value); + externalID.setType(identifierType); + externalID.setValue(metadataValue.getValue()); externalID.setRelationship(relationship); return externalID; } /** - * Creates an instance of WorkType from the given item, taking the value fom the + * Creates an instance of WorkType from the given item, taking the value from the * configured metadata field (orcid.mapping.work.type). */ private WorkType getWorkType(Context context, Item item) { 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 261f8ef9a9f..d69e8842f5a 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 @@ -224,7 +224,7 @@ private List findAllEntitiesLinkableWith(Context context, Item profile, St return findRelationshipsByItem(context, profile).stream() .map(relationship -> getRelatedItem(relationship, profile)) - .filter(item -> entityType.equals(itemService.getEntityTypeLabel(item))) + .filter(item -> item.isArchived() && entityType.equals(itemService.getEntityTypeLabel(item))) .collect(Collectors.toList()); } diff --git a/dspace-api/src/main/java/org/dspace/profile/ResearcherProfileServiceImpl.java b/dspace-api/src/main/java/org/dspace/profile/ResearcherProfileServiceImpl.java index 80bbd68fd19..cb298bc177f 100644 --- a/dspace-api/src/main/java/org/dspace/profile/ResearcherProfileServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/profile/ResearcherProfileServiceImpl.java @@ -283,6 +283,8 @@ private Item createProfileItem(Context context, EPerson ePerson, Collection coll itemService.addMetadata(context, item, "dc", "title", null, null, fullName); itemService.addMetadata(context, item, "person", "email", null, null, ePerson.getEmail()); itemService.addMetadata(context, item, "dspace", "object", "owner", null, fullName, id, CF_ACCEPTED); + itemService.addMetadata(context, item, "person", "familyName", null, null, ePerson.getLastName()); + itemService.addMetadata(context, item, "person", "givenName", null, null, ePerson.getFirstName()); item = installItemService.installItem(context, workspaceItem); diff --git a/dspace-api/src/main/java/org/dspace/scripts/DSpaceRunnable.java b/dspace-api/src/main/java/org/dspace/scripts/DSpaceRunnable.java index 2ea0a52d6e3..8f905e01511 100644 --- a/dspace-api/src/main/java/org/dspace/scripts/DSpaceRunnable.java +++ b/dspace-api/src/main/java/org/dspace/scripts/DSpaceRunnable.java @@ -117,7 +117,7 @@ private void handleHelpCommandLine() { * @param args The primitive array of Strings representing the parameters * @throws ParseException If something goes wrong */ - private StepResult parse(String[] args) throws ParseException { + protected StepResult parse(String[] args) throws ParseException { commandLine = new DefaultParser().parse(getScriptConfiguration().getOptions(), args); setup(); return StepResult.Continue; diff --git a/dspace-api/src/main/java/org/dspace/sort/OrderFormatTitle.java b/dspace-api/src/main/java/org/dspace/sort/OrderFormatTitle.java index b745f0719cb..f6f9aaa38e5 100644 --- a/dspace-api/src/main/java/org/dspace/sort/OrderFormatTitle.java +++ b/dspace-api/src/main/java/org/dspace/sort/OrderFormatTitle.java @@ -9,7 +9,6 @@ import org.dspace.text.filter.DecomposeDiactritics; import org.dspace.text.filter.LowerCaseAndTrim; -import org.dspace.text.filter.StandardInitialArticleWord; import org.dspace.text.filter.StripDiacritics; import org.dspace.text.filter.TextFilter; @@ -20,7 +19,7 @@ */ public class OrderFormatTitle extends AbstractTextFilterOFD { { - filters = new TextFilter[] {new StandardInitialArticleWord(), + filters = new TextFilter[] { new DecomposeDiactritics(), new StripDiacritics(), new LowerCaseAndTrim()}; diff --git a/dspace-api/src/main/java/org/dspace/sort/OrderFormatTitleMarc21.java b/dspace-api/src/main/java/org/dspace/sort/OrderFormatTitleMarc21.java index fa9ba297258..9148ca2a988 100644 --- a/dspace-api/src/main/java/org/dspace/sort/OrderFormatTitleMarc21.java +++ b/dspace-api/src/main/java/org/dspace/sort/OrderFormatTitleMarc21.java @@ -9,7 +9,6 @@ import org.dspace.text.filter.DecomposeDiactritics; import org.dspace.text.filter.LowerCaseAndTrim; -import org.dspace.text.filter.MARC21InitialArticleWord; import org.dspace.text.filter.StripDiacritics; import org.dspace.text.filter.StripLeadingNonAlphaNum; import org.dspace.text.filter.TextFilter; @@ -21,7 +20,7 @@ */ public class OrderFormatTitleMarc21 extends AbstractTextFilterOFD { { - filters = new TextFilter[] {new MARC21InitialArticleWord(), + filters = new TextFilter[] { new DecomposeDiactritics(), new StripDiacritics(), new StripLeadingNonAlphaNum(), diff --git a/dspace-api/src/main/java/org/dspace/statistics/SolrLoggerServiceImpl.java b/dspace-api/src/main/java/org/dspace/statistics/SolrLoggerServiceImpl.java index 35f86e3a1c0..58a1d93b4f5 100644 --- a/dspace-api/src/main/java/org/dspace/statistics/SolrLoggerServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/statistics/SolrLoggerServiceImpl.java @@ -28,6 +28,7 @@ import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Arrays; import java.util.Calendar; import java.util.Date; import java.util.EnumSet; @@ -232,6 +233,10 @@ public void postView(DSpaceObject dspaceObject, HttpServletRequest request, throw new RuntimeException(e); } + if (dspaceObject instanceof Bitstream && !isBitstreamLoggable((Bitstream) dspaceObject)) { + return; + } + if (solr == null) { return; } @@ -279,6 +284,10 @@ public void postView(DSpaceObject dspaceObject, @Override public void postView(DSpaceObject dspaceObject, String ip, String userAgent, String xforwardedfor, EPerson currentUser, String referrer) { + if (dspaceObject instanceof Bitstream && !isBitstreamLoggable((Bitstream) dspaceObject)) { + return; + } + if (solr == null) { return; } @@ -1713,4 +1722,35 @@ public Object anonymizeIp(String ip) throws UnknownHostException { throw new UnknownHostException("unknown ip format"); } + + /** + * Checks if a given Bitstream's bundles are configured to be logged in Solr statistics. + * + * @param bitstream The bitstream to check. + * @return {@code true} if the bitstream event should be logged, {@code false} otherwise. + */ + private boolean isBitstreamLoggable(Bitstream bitstream) { + String[] allowedBundles = configurationService + .getArrayProperty("solr-statistics.query.filter.bundles"); + if (allowedBundles == null || allowedBundles.length == 0) { + return true; + } + List allowedBundlesList = Arrays.asList(allowedBundles); + try { + List actualBundles = bitstream.getBundles(); + if (actualBundles.isEmpty()) { + return true; + } + for (Bundle bundle : actualBundles) { + if (allowedBundlesList.contains(bundle.getName())) { + return true; + } + } + } catch (SQLException e) { + log.error("Error checking bitstream bundles for logging statistics for bitstream {}", + bitstream.getID(), e); + return true; + } + return false; + } } diff --git a/dspace-api/src/main/java/org/dspace/statistics/export/processor/ExportEventProcessor.java b/dspace-api/src/main/java/org/dspace/statistics/export/processor/ExportEventProcessor.java index 609298779d3..562b8547ce9 100644 --- a/dspace-api/src/main/java/org/dspace/statistics/export/processor/ExportEventProcessor.java +++ b/dspace-api/src/main/java/org/dspace/statistics/export/processor/ExportEventProcessor.java @@ -136,9 +136,10 @@ protected String getBaseParameters(Item item) .append(URLEncoder.encode(clientUA, UTF_8)); String hostName = Utils.getHostName(configurationService.getProperty("dspace.ui.url")); + String oaiPrefix = configurationService.getProperty("oai.identifier.prefix"); data.append("&").append(URLEncoder.encode("rft.artnum", UTF_8)).append("="). - append(URLEncoder.encode("oai:" + hostName + ":" + item + append(URLEncoder.encode("oai:" + oaiPrefix + ":" + item .getHandle(), UTF_8)); data.append("&").append(URLEncoder.encode("rfr_dat", UTF_8)).append("=") .append(URLEncoder.encode(referer, UTF_8)); diff --git a/dspace-api/src/main/java/org/dspace/storage/bitstore/BitstreamStorageServiceImpl.java b/dspace-api/src/main/java/org/dspace/storage/bitstore/BitstreamStorageServiceImpl.java index 85da914644d..47ce7be7617 100644 --- a/dspace-api/src/main/java/org/dspace/storage/bitstore/BitstreamStorageServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/storage/bitstore/BitstreamStorageServiceImpl.java @@ -431,7 +431,7 @@ public void migrate(Context context, Integer assetstoreSource, Integer assetstor //modulo if ((processedCounter % batchCommitSize) == 0) { log.info("Migration Commit Checkpoint: " + processedCounter); - context.dispatchEvents(); + context.commit(); } } diff --git a/dspace-api/src/main/java/org/dspace/storage/bitstore/DSBitStoreService.java b/dspace-api/src/main/java/org/dspace/storage/bitstore/DSBitStoreService.java index 60b8d0716cb..4b8aa74eb30 100644 --- a/dspace-api/src/main/java/org/dspace/storage/bitstore/DSBitStoreService.java +++ b/dspace-api/src/main/java/org/dspace/storage/bitstore/DSBitStoreService.java @@ -19,9 +19,11 @@ import java.util.List; import java.util.Map; +import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.Logger; import org.dspace.content.Bitstream; import org.dspace.core.Utils; +import org.dspace.services.factory.DSpaceServicesFactory; /** * Native DSpace (or "Directory Scatter" if you prefer) asset store. @@ -252,10 +254,13 @@ public File getFile(Bitstream bitstream) throws IOException { } File bitstreamFile = new File(bufFilename.toString()); Path normalizedPath = bitstreamFile.toPath().normalize(); - if (!normalizedPath.startsWith(baseDir.getAbsolutePath())) { + String[] allowedAssetstoreRoots = DSpaceServicesFactory.getInstance().getConfigurationService() + .getArrayProperty("assetstore.allowed.roots", new String[]{}); + if (!normalizedPath.startsWith(baseDir.getCanonicalPath()) + && !StringUtils.startsWithAny(normalizedPath.toString(), allowedAssetstoreRoots)) { log.error("Bitstream path outside of assetstore root requested:" + "bitstream={}, path={}, assetstore={}", - bitstream.getID(), normalizedPath, baseDir.getAbsolutePath()); + bitstream.getID(), normalizedPath, baseDir.getCanonicalPath()); throw new IOException("Illegal bitstream path constructed"); } return bitstreamFile; diff --git a/dspace-api/src/main/java/org/dspace/storage/bitstore/S3BitStoreService.java b/dspace-api/src/main/java/org/dspace/storage/bitstore/S3BitStoreService.java index ca4fe0b8c93..d70cad3bbe9 100644 --- a/dspace-api/src/main/java/org/dspace/storage/bitstore/S3BitStoreService.java +++ b/dspace-api/src/main/java/org/dspace/storage/bitstore/S3BitStoreService.java @@ -10,10 +10,10 @@ import static java.lang.String.valueOf; import java.io.File; -import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.net.URI; import java.security.DigestInputStream; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -21,34 +21,20 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.function.Supplier; -import javax.validation.constraints.NotNull; - -import com.amazonaws.AmazonClientException; -import com.amazonaws.auth.AWSCredentials; -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.client.builder.AwsClientBuilder; -import com.amazonaws.regions.Region; -import com.amazonaws.regions.Regions; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.AmazonS3ClientBuilder; -import com.amazonaws.services.s3.model.AmazonS3Exception; -import com.amazonaws.services.s3.model.GetObjectRequest; -import com.amazonaws.services.s3.model.ObjectMetadata; -import com.amazonaws.services.s3.transfer.Download; -import com.amazonaws.services.s3.transfer.TransferManager; -import com.amazonaws.services.s3.transfer.TransferManagerBuilder; -import com.amazonaws.services.s3.transfer.Upload; + import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.DefaultParser; import org.apache.commons.cli.HelpFormatter; import org.apache.commons.cli.Option; import org.apache.commons.cli.Options; import org.apache.commons.cli.ParseException; +import org.apache.commons.io.IOUtils; import org.apache.commons.io.output.NullOutputStream; import org.apache.commons.lang3.StringUtils; -import org.apache.http.HttpStatus; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.dspace.content.Bitstream; @@ -59,6 +45,20 @@ import org.dspace.storage.bitstore.service.BitstreamStorageService; import org.dspace.util.FunctionalUtils; import org.springframework.beans.factory.annotation.Autowired; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.core.async.AsyncRequestBody; +import software.amazon.awssdk.core.async.AsyncResponseTransformer; +import software.amazon.awssdk.http.HttpStatusCode; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.S3CrtAsyncClientBuilder; +import software.amazon.awssdk.services.s3.model.ChecksumAlgorithm; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchBucketException; +import software.amazon.awssdk.services.s3.model.S3Exception; /** * Asset store using Amazon's Simple Storage Service (S3). @@ -67,7 +67,7 @@ * * @author Richard Rodgers, Peter Dietz * @author Vincenzo Mecca (vins01-4science - vincenzo.mecca at 4science.com) - * + * @author Mark Patton */ public class S3BitStoreService extends BaseBitStoreService { @@ -84,33 +84,28 @@ public class S3BitStoreService extends BaseBitStoreService { */ static final String CSA = "MD5"; - // These settings control the way an identifier is hashed into - // directory and file names - // - // With digitsPerLevel 2 and directoryLevels 3, an identifier - // like 12345678901234567890 turns into the relative name - // /12/34/56/12345678901234567890. - // - // You should not change these settings if you have data in the - // asset store, as the BitstreamStorageManager will be unable - // to find your existing data. - protected static final int digitsPerLevel = 2; - protected static final int directoryLevels = 3; - private boolean enabled = false; + /** + * Override AWS endpoint if not null + */ + private String endpoint = null; + + /** + * CLARIN: use path-style addressing against the overridden endpoint. Vanilla hardcodes `true` + * whenever an endpoint is set; the fork keeps it configurable through + * `assetstore.s3.pathStyleAccessEnabled`, which is what the fork's v1 client did. + */ + private boolean pathStyleAccessEnabled = false; + private String awsAccessKey; private String awsSecretKey; private String awsRegionName; private boolean useRelativePath; - - private String endpoint; - private boolean pathStyleAccessEnabled; - - /** - * The maximum size of individual chunk to download from S3 when a file is accessed. Default 5Mb - */ - private long bufferSize = 5 * 1024 * 1024; + private double targetThroughputGbps = 10.0; + private long minPartSizeBytes = 8 * 1024 * 1024L; + private ChecksumAlgorithm s3ChecksumAlgorithm = ChecksumAlgorithm.CRC32; + private Integer maxConcurrency = null; /** * container for all the assets @@ -124,14 +119,11 @@ public class S3BitStoreService extends BaseBitStoreService { /** * S3 service + * + * CLARIN: `protected` rather than vanilla's `private`, so that {@link SyncS3BitStoreService} and + * {@link S3DirectDownloadServiceImpl} reuse this client instead of opening a second one. */ - protected AmazonS3 s3Service = null; - - /** - * S3 transfer manager - * this is reused between put calls to use less resources for multiple uploads - */ - protected TransferManager tm = null; + protected S3AsyncClient s3AsyncClient = null; private static final ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); @@ -141,45 +133,55 @@ public class S3BitStoreService extends BaseBitStoreService { * * @param regions wanted regions in client * @param awsCredentials credentials of the client + * @param endpoint custom AWS endpoint + * @param targetThroughput target throughput in Gbps + * @param minPartSize minimum part size in bytes + * @param maxConcurrency maximum number of concurrent requests + * @param pathStyleAccessEnabled use path-style addressing when the endpoint is overridden * @return builder with the specified parameters */ - protected static Supplier amazonClientBuilderBy( - @NotNull Regions regions, - @NotNull AWSCredentials awsCredentials + protected static Supplier amazonClientBuilderBy( + Region region, + AwsCredentialsProvider credentialsProvider, + String endpoint, + double targetThroughput, + long minPartSize, + Integer maxConcurrency, + boolean pathStyleAccessEnabled ) { - return () -> AmazonS3ClientBuilder.standard() - .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) - .withRegion(regions) - .build(); - } + return () -> { + S3CrtAsyncClientBuilder crtBuilder = S3AsyncClient.crtBuilder(); - /** - * Utility method for generate AmazonS3 builder with specific endpoint - * - * @param endpointConfiguration configuration of endpoint - * @param awsCredentials credentials of the client - * @param pathStyleAccessEnabled enable path style access to S3 service - * @return builder with the specified parameters - */ - protected static Supplier amazonClientBuilderBy( - @NotNull AwsClientBuilder.EndpointConfiguration endpointConfiguration, - @NotNull AWSCredentials awsCredentials, - @NotNull boolean pathStyleAccessEnabled - ) { - return () -> AmazonS3ClientBuilder.standard() - .withPathStyleAccessEnabled( pathStyleAccessEnabled) - .withEndpointConfiguration(endpointConfiguration) - .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)).build(); + if (credentialsProvider != null) { + crtBuilder.credentialsProvider(credentialsProvider); + } + + if (region != null) { + crtBuilder.region(region); + } + + if (maxConcurrency != null) { + crtBuilder.maxConcurrency(maxConcurrency); + } + + if (StringUtils.isNotBlank(endpoint)) { + crtBuilder.endpointOverride(URI.create(endpoint)); + crtBuilder.forcePathStyle(pathStyleAccessEnabled); + } + + return crtBuilder.targetThroughputInGbps(targetThroughput).minimumPartSizeInBytes(minPartSize).build(); + }; } + public S3BitStoreService() {} /** * This constructor is used for test purpose. * - * @param s3Service AmazonS3 service + * @param s3AsyncClient AmazonS3 service */ - protected S3BitStoreService(AmazonS3 s3Service) { - this.s3Service = s3Service; + protected S3BitStoreService(S3AsyncClient s3AsyncClient) { + this.s3AsyncClient = s3AsyncClient; } @Override @@ -196,47 +198,39 @@ public boolean isEnabled() { */ @Override public void init() throws IOException { - if (this.isInitialized() || !this.isEnabled()) { return; } try { - if (StringUtils.isNotBlank(getEndpoint())) { - log.info("Creating s3service from different endpoint than amazon: " + getEndpoint()); - BasicAWSCredentials credentials = new BasicAWSCredentials(getAwsAccessKey(), getAwsSecretKey()); - AwsClientBuilder.EndpointConfiguration ec = - new AwsClientBuilder.EndpointConfiguration(getEndpoint(), ""); - s3Service = FunctionalUtils.getDefaultOrBuild( - this.s3Service, - amazonClientBuilderBy(ec, credentials, getPathStyleAccessEnabled()) - ); - } else if (StringUtils.isNotBlank(getAwsAccessKey()) && StringUtils.isNotBlank(getAwsSecretKey())) { + if (StringUtils.isNotBlank(getAwsAccessKey()) && StringUtils.isNotBlank(getAwsSecretKey())) { log.warn("Use local defined S3 credentials"); // region - Regions regions = Regions.DEFAULT_REGION; + Region region = Region.US_EAST_1; if (StringUtils.isNotBlank(awsRegionName)) { try { - regions = Regions.fromName(awsRegionName); + region = Region.of(awsRegionName); } catch (IllegalArgumentException e) { log.warn("Invalid aws_region: " + awsRegionName); } } + // init client - s3Service = FunctionalUtils.getDefaultOrBuild( - this.s3Service, + s3AsyncClient = FunctionalUtils.getDefaultOrBuild( + this.s3AsyncClient, amazonClientBuilderBy( - regions, - new BasicAWSCredentials(getAwsAccessKey(), getAwsSecretKey()) - ) + region, + StaticCredentialsProvider.create(AwsBasicCredentials.create(getAwsAccessKey(), + getAwsSecretKey())), endpoint, targetThroughputGbps, + minPartSizeBytes, maxConcurrency, pathStyleAccessEnabled) ); - log.warn("S3 Region set to: " + regions.getName()); + log.warn("S3 Region set to: " + region.id()); } else { log.info("Using a IAM role or aws environment credentials"); - s3Service = FunctionalUtils.getDefaultOrBuild( - this.s3Service, - AmazonS3ClientBuilder::defaultClient - ); + s3AsyncClient = FunctionalUtils.getDefaultOrBuild( + this.s3AsyncClient, + amazonClientBuilderBy(null, null , endpoint, targetThroughputGbps, + minPartSizeBytes, maxConcurrency, pathStyleAccessEnabled)); } // bucket name @@ -247,13 +241,10 @@ public void init() throws IOException { log.warn("S3 BucketName is not configured, setting default: " + bucketName); } - try { - if (!s3Service.doesBucketExistV2(bucketName)) { - s3Service.createBucket(bucketName); - log.info("Creating new S3 Bucket: " + bucketName); - } - } catch (AmazonClientException e) { - throw new IOException(e); + + if (!doesBucketExist(bucketName)) { + s3AsyncClient.createBucket(r -> r.bucket(bucketName)).join(); + log.info("Creating new S3 Bucket: " + bucketName); } this.initialized = true; log.info("AWS S3 Assetstore ready to go! bucket:" + bucketName); @@ -261,13 +252,30 @@ public void init() throws IOException { this.initialized = false; log.error("Can't initialize this store!", e); } + } - log.info("AWS S3 Assetstore ready to go! bucket:" + bucketName); + /** + * @param bucketName + * @return whether or not the specified bucket exists + */ + public boolean doesBucketExist(String bucketName ) { + try { + s3AsyncClient.headBucket(r -> r.bucket(bucketName)).join(); + return true; + } catch (CompletionException ce) { + Throwable cause = ce.getCause(); + if (cause instanceof NoSuchBucketException + || (cause instanceof S3Exception + && ((S3Exception) cause).statusCode() == HttpStatusCode.NOT_FOUND)) { + return false; + } - tm = FunctionalUtils.getDefaultOrBuild(tm, () -> TransferManagerBuilder.standard() - .withAlwaysCalculateMultipartMd5(true) - .withS3Client(s3Service) - .build()); + // CLARIN: only a genuinely absent bucket may answer "false". Reporting a 403 as "absent" makes + // init() try to create a bucket that already exists, which a least-privilege policy denies - + // and the assetstore then comes up dead. The v1 SDK's doesBucketExistV2 drew the same line. + log.error("headBucket(" + bucketName + ") failed for a reason other than an absent bucket", cause); + throw ce; + } } /** @@ -295,30 +303,33 @@ public InputStream get(Bitstream bitstream) throws IOException { if (isRegisteredBitstream(key)) { key = key.substring(REGISTERED_FLAG.length()); } - return new S3LazyInputStream(key, bufferSize, bitstream.getSizeBytes()); + + final String objectKey = key; + + try { + return s3AsyncClient.getObject(r -> r.bucket(bucketName).key(objectKey), + AsyncResponseTransformer.toBlockingInputStream()).join(); + } catch (CompletionException e) { + throw new IOException(e.getCause()); + } } + /** + * CLARIN: download the bitstream from S3 into a local temp File (used by the file-preview feature). + * Reuses get(Bitstream) (AWS SDK v2 async client) and streams it to a temp file. The dtq-dev 7.x + * version used AWS SDK v1 (tm.download), which does not apply to the SDK-v2 client. + * + * Kept identical to the fork's v9 implementation (5e930d1f2a) so the two branches do not diverge. + */ @Override public File getFile(Bitstream bitstream) throws IOException { - String key = getFullKey(bitstream.getInternalId()); - // Strip -R from bitstream key if it's registered - if (isRegisteredBitstream(key)) { - key = key.substring(REGISTERED_FLAG.length()); - } - try { - File tempFile = File.createTempFile("s3-disk-copy-" + UUID.randomUUID(), "temp"); - tempFile.deleteOnExit(); - - GetObjectRequest getObjectRequest = new GetObjectRequest(bucketName, key); - - Download download = tm.download(getObjectRequest, tempFile); - download.waitForCompletion(); - - return tempFile; - } catch (AmazonClientException | InterruptedException e) { - log.error("getFile(" + key + ")", e); - throw new IOException(e); + File tempFile = File.createTempFile("s3-disk-copy-" + UUID.randomUUID(), ".temp"); + tempFile.deleteOnExit(); + try (InputStream in = this.get(bitstream); + FileOutputStream out = new FileOutputStream(tempFile)) { + IOUtils.copy(in, out); } + return tempFile; } /** @@ -335,44 +346,40 @@ public File getFile(Bitstream bitstream) throws IOException { @Override public void put(Bitstream bitstream, InputStream in) throws IOException { String key = getFullKey(bitstream.getInternalId()); - //Copy istream to temp file, and send the file, with some metadata - File scratchFile = File.createTempFile(bitstream.getInternalId(), "s3bs"); - try ( - FileOutputStream fos = new FileOutputStream(scratchFile); - // Read through a digest input stream that will work out the MD5 - DigestInputStream dis = new DigestInputStream(in, MessageDigest.getInstance(CSA)); - ) { - Utils.bufferedCopy(dis, fos); - in.close(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + + try (DigestInputStream dis = new DigestInputStream(in, MessageDigest.getInstance(CSA))) { + AsyncRequestBody body = AsyncRequestBody.fromInputStream(dis, null, executor); - Upload upload = tm.upload(bucketName, key, scratchFile); + s3AsyncClient.putObject(b -> b.bucket(bucketName).key(key).checksumAlgorithm(s3ChecksumAlgorithm), + body).join(); - upload.waitForUploadResult(); + bitstream.setSizeBytes(s3AsyncClient.headObject(r -> r.bucket(bucketName).key(key)) + .join().contentLength()); - bitstream.setSizeBytes(scratchFile.length()); // we cannot use the S3 ETAG here as it could be not a MD5 in case of multipart upload (large files) or if // the bucket is encrypted bitstream.setChecksum(Utils.toHex(dis.getMessageDigest().digest())); bitstream.setChecksumAlgorithm(CSA); - - } catch (AmazonClientException | IOException | InterruptedException e) { + } catch (CompletionException e) { + log.error("put(" + bitstream.getInternalId() + ", is)", e.getCause()); + throw new IOException(e.getCause()); + } catch (IOException e) { log.error("put(" + bitstream.getInternalId() + ", is)", e); throw new IOException(e); } catch (NoSuchAlgorithmException nsae) { // Should never happen log.warn("Caught NoSuchAlgorithmException", nsae); } finally { - if (!scratchFile.delete()) { - scratchFile.deleteOnExit(); - } + executor.shutdown(); + in.close(); } } /** * Obtain technical metadata about an asset in the asset store. * - * Checksum used is (ETag) hex encoded 128-bit MD5 digest of an object's content as calculated by Amazon S3 - * (Does not use getContentMD5, as that is 128-bit MD5 digest calculated on caller's side) + * The MD5 checksum is calculated locally because it is not supported by AWS. * * @param bitstream The asset to describe * @param attrs A List of desired metadata fields @@ -383,7 +390,6 @@ public void put(Bitstream bitstream, InputStream in) throws IOException { */ @Override public Map about(Bitstream bitstream, List attrs) throws IOException { - String key = getFullKey(bitstream.getInternalId()); // If this is a registered bitstream, strip the -R prefix before retrieving if (isRegisteredBitstream(key)) { @@ -393,20 +399,18 @@ public Map about(Bitstream bitstream, List attrs) throws Map metadata = new HashMap<>(); try { + final String objectKey = key; + HeadObjectResponse response = s3AsyncClient.headObject(r -> r.bucket(bucketName).key(objectKey)).join(); - ObjectMetadata objectMetadata = s3Service.getObjectMetadata(bucketName, key); - if (objectMetadata != null) { - putValueIfExistsKey(attrs, metadata, "size_bytes", objectMetadata.getContentLength()); - putValueIfExistsKey(attrs, metadata, "modified", valueOf(objectMetadata.getLastModified().getTime())); - } - + putValueIfExistsKey(attrs, metadata, "size_bytes", response.contentLength()); + putValueIfExistsKey(attrs, metadata, "modified", valueOf(response.lastModified().toEpochMilli())); putValueIfExistsKey(attrs, metadata, "checksum_algorithm", CSA); if (attrs.contains("checksum")) { try (InputStream in = get(bitstream); DigestInputStream dis = new DigestInputStream(in, MessageDigest.getInstance(CSA)) ) { - Utils.copy(dis, NullOutputStream.NULL_OUTPUT_STREAM); + Utils.copy(dis, NullOutputStream.INSTANCE); byte[] md5Digest = dis.getMessageDigest().digest(); metadata.put("checksum", Utils.toHex(md5Digest)); } catch (NoSuchAlgorithmException nsae) { @@ -416,15 +420,16 @@ public Map about(Bitstream bitstream, List attrs) throws } return metadata; - } catch (AmazonS3Exception e) { - if (e.getStatusCode() == HttpStatus.SC_NOT_FOUND) { - return metadata; + } catch (CompletionException e) { + if (e.getCause() instanceof AwsServiceException) { + if (((AwsServiceException)e.getCause()).statusCode() == HttpStatusCode.NOT_FOUND) { + return metadata; + } } - } catch (AmazonClientException e) { + log.error("about(" + key + ", attrs)", e); throw new IOException(e); } - return metadata; } /** @@ -437,10 +442,10 @@ public Map about(Bitstream bitstream, List attrs) throws public void remove(Bitstream bitstream) throws IOException { String key = getFullKey(bitstream.getInternalId()); try { - s3Service.deleteObject(bucketName, key); - } catch (AmazonClientException e) { - log.error("remove(" + key + ")", e); - throw new IOException(e); + s3AsyncClient.deleteObject(r -> r.bucket(bucketName).key(key)).join(); + } catch (CompletionException e) { + log.error("remove(" + key + ")", e.getCause()); + throw new IOException(e.getCause()); } } @@ -551,6 +556,38 @@ public void setUseRelativePath(boolean useRelativePath) { this.useRelativePath = useRelativePath; } + public double getTargetThroughputGbps() { + return targetThroughputGbps; + } + + public void setTargetThroughputGbps(double targetThroughputGbps) { + this.targetThroughputGbps = targetThroughputGbps; + } + + public long getMinPartSizeBytes() { + return minPartSizeBytes; + } + + public void setMinPartSizeBytes(long minPartSizeBytes) { + this.minPartSizeBytes = minPartSizeBytes; + } + + public ChecksumAlgorithm getS3ChecksumAlgorithm() { + return s3ChecksumAlgorithm; + } + + public void setS3ChecksumAlgorithm(ChecksumAlgorithm s3ChecksumAlgorithm) { + this.s3ChecksumAlgorithm = s3ChecksumAlgorithm; + } + + public Integer getMaxConcurrency() { + return maxConcurrency; + } + + public void setMaxConcurrency(Integer maxConcurrency) { + this.maxConcurrency = maxConcurrency; + } + public String getEndpoint() { return endpoint; } @@ -607,73 +644,18 @@ public static void main(String[] args) throws Exception { S3BitStoreService store = new S3BitStoreService(); - AWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey)); - store.s3Service = AmazonS3ClientBuilder.standard() - .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) - .build(); - - //Todo configurable region - Region usEast1 = Region.getRegion(Regions.US_EAST_1); - store.s3Service.setRegion(usEast1); + // Todo configurable region + store.s3AsyncClient = S3AsyncClient.builder().credentialsProvider(credentialsProvider). + region(Region.US_EAST_1).build(); // get hostname of DSpace UI to use to name bucket String hostname = Utils.getHostName(configurationService.getProperty("dspace.ui.url")); //Bucketname should be lowercase store.bucketName = DEFAULT_BUCKET_PREFIX + hostname + ".s3test"; - store.s3Service.createBucket(store.bucketName); - /* Broken in DSpace 6 TODO Refactor - // time everything, todo, swtich to caliper - long start = System.currentTimeMillis(); - // Case 1: store a file - String id = store.generateId(); - System.out.print("put() file " + assetFile + " under ID " + id + ": "); - FileInputStream fis = new FileInputStream(assetFile); - //TODO create bitstream for assetfile... - Map attrs = store.put(fis, id); - long now = System.currentTimeMillis(); - System.out.println((now - start) + " msecs"); - start = now; - // examine the metadata returned - Iterator iter = attrs.keySet().iterator(); - System.out.println("Metadata after put():"); - while (iter.hasNext()) - { - String key = (String)iter.next(); - System.out.println( key + ": " + (String)attrs.get(key) ); - } - // Case 2: get metadata and compare - System.out.print("about() file with ID " + id + ": "); - Map attrs2 = store.about(id, attrs); - now = System.currentTimeMillis(); - System.out.println((now - start) + " msecs"); - start = now; - iter = attrs2.keySet().iterator(); - System.out.println("Metadata after about():"); - while (iter.hasNext()) - { - String key = (String)iter.next(); - System.out.println( key + ": " + (String)attrs.get(key) ); - } - // Case 3: retrieve asset and compare bits - System.out.print("get() file with ID " + id + ": "); - java.io.FileOutputStream fos = new java.io.FileOutputStream(assetFile+".echo"); - InputStream in = store.get(id); - Utils.bufferedCopy(in, fos); - fos.close(); - in.close(); - now = System.currentTimeMillis(); - System.out.println((now - start) + " msecs"); - start = now; - // Case 4: remove asset - System.out.print("remove() file with ID: " + id + ": "); - store.remove(id); - now = System.currentTimeMillis(); - System.out.println((now - start) + " msecs"); - System.out.flush(); - // should get nothing back now - will throw exception - store.get(id); -*/ + store.s3AsyncClient.createBucket(r -> r.bucket(store.bucketName)).join(); } /** @@ -684,85 +666,4 @@ public static void main(String[] args) throws Exception { public boolean isRegisteredBitstream(String internalId) { return internalId.startsWith(REGISTERED_FLAG); } - - public void setBufferSize(long bufferSize) { - this.bufferSize = bufferSize; - } - - /** - * This inner class represent an InputStream that uses temporary files to - * represent chunk of the object downloaded from S3. When the input stream is - * read the class look first to the current chunk and download a new one once if - * the current one as been fully read. The class is responsible to close a chunk - * as soon as a new one is retrieved, the last chunk is closed when the input - * stream itself is closed or the last byte is read (the first of the two) - */ - public class S3LazyInputStream extends InputStream { - private InputStream currentChunkStream; - private String objectKey; - private long endOfChunk = -1; - private long chunkMaxSize; - private long currPos = 0; - private long fileSize; - - public S3LazyInputStream(String objectKey, long chunkMaxSize, long fileSize) throws IOException { - this.objectKey = objectKey; - this.chunkMaxSize = chunkMaxSize; - this.endOfChunk = 0; - this.fileSize = fileSize; - downloadChunk(); - } - - @Override - public int read() throws IOException { - // is the current chunk completely read and other are available? - if (currPos == endOfChunk && currPos < fileSize) { - currentChunkStream.close(); - downloadChunk(); - } - - int byteRead = currPos < endOfChunk ? currentChunkStream.read() : -1; - // do we get any data or are we at the end of the file? - if (byteRead != -1) { - currPos++; - } else { - currentChunkStream.close(); - } - return byteRead; - } - - /** - * This method download the next chunk from S3 - * - * @throws IOException - * @throws FileNotFoundException - */ - private void downloadChunk() throws IOException, FileNotFoundException { - // Create a DownloadFileRequest with the desired byte range - long startByte = currPos; // Start byte (inclusive) - long endByte = Long.min(startByte + chunkMaxSize - 1, fileSize - 1); // End byte (inclusive) - GetObjectRequest getRequest = new GetObjectRequest(bucketName, objectKey) - .withRange(startByte, endByte); - - File currentChunkFile = File.createTempFile("s3-disk-copy-" + UUID.randomUUID(), "temp"); - currentChunkFile.deleteOnExit(); - try { - Download download = tm.download(getRequest, currentChunkFile); - download.waitForCompletion(); - currentChunkStream = new DeleteOnCloseFileInputStream(currentChunkFile); - endOfChunk = endOfChunk + download.getProgress().getBytesTransferred(); - } catch (AmazonClientException | InterruptedException e) { - currentChunkFile.delete(); - throw new IOException(e); - } - } - - @Override - public void close() throws IOException { - if (currentChunkStream != null) { - currentChunkStream.close(); - } - } - - } } diff --git a/dspace-api/src/main/java/org/dspace/storage/bitstore/S3DirectDownloadServiceImpl.java b/dspace-api/src/main/java/org/dspace/storage/bitstore/S3DirectDownloadServiceImpl.java index 455f34d8f01..fce4332ea9c 100644 --- a/dspace-api/src/main/java/org/dspace/storage/bitstore/S3DirectDownloadServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/storage/bitstore/S3DirectDownloadServiceImpl.java @@ -8,23 +8,33 @@ package org.dspace.storage.bitstore; import java.io.IOException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.util.Date; - -import com.amazonaws.HttpMethod; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; +import java.net.URI; +import java.time.Duration; +import java.util.concurrent.CompletionException; + +import org.apache.commons.lang3.StringUtils; import org.dspace.services.ConfigurationService; import org.dspace.storage.bitstore.service.S3DirectDownloadService; +import org.dspace.util.ContentDispositionUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.http.HttpStatusCode; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; /** * Implementation of the S3DirectDownloadService interface for generating presigned URLs for S3 downloads. - * This implementation uses the AmazonS3 client provided by the S3BitStoreService. + * This implementation reuses the S3 client provided by the S3BitStoreService and derives the presigner + * from the same configuration. * * @author Milan Majchrak (dspace at dataquest.sk) */ @@ -38,16 +48,21 @@ public class S3DirectDownloadServiceImpl implements S3DirectDownloadService { @Autowired private S3BitStoreService s3BitStoreService; - private AmazonS3 s3Client; + private S3AsyncClient s3Client; + + /** + * Unlike the v1 SDK, presigning in v2 is done by a separate object rather than by the client itself. + */ + private S3Presigner s3Presigner; private void init() { - // Use the S3BitStoreService to get the AmazonS3 client - do not create a new one - this.s3Client = s3BitStoreService.s3Service; + // Use the S3BitStoreService to get the S3 client - do not create a new one + this.s3Client = s3BitStoreService.s3AsyncClient; if (this.s3Client == null) { try { s3BitStoreService.init(); - this.s3Client = s3BitStoreService.s3Service; + this.s3Client = s3BitStoreService.s3AsyncClient; } catch (IOException e) { throw new RuntimeException("Failed to initialize S3 client from S3BitStoreService", e); } @@ -57,39 +72,130 @@ private void init() { "on S3BitStoreService."); } } + + if (this.s3Presigner == null) { + this.s3Presigner = buildPresigner(); + } } + /** + * Build a presigner from the same credentials, region and endpoint the bitstore client uses, so that + * the signed URL points at the same S3 provider the assets actually live on. + */ + private S3Presigner buildPresigner() { + S3Presigner.Builder builder = S3Presigner.builder(); + + String accessKey = s3BitStoreService.getAwsAccessKey(); + String secretKey = s3BitStoreService.getAwsSecretKey(); + if (StringUtils.isNotBlank(accessKey) && StringUtils.isNotBlank(secretKey)) { + builder.credentialsProvider( + StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))); + } + // otherwise fall back to the default provider chain (IAM role / environment), as S3BitStoreService does + + // Mirror S3BitStoreService exactly. With explicit credentials it falls back to us-east-1; without + // them it leaves the region unset so the default provider chain resolves it. Hardcoding us-east-1 + // in the no-credentials case would sign against the wrong region on an IAM role outside us-east-1, + // and the signature would be rejected. + String regionName = s3BitStoreService.getAwsRegionName(); + if (StringUtils.isNotBlank(accessKey) && StringUtils.isNotBlank(secretKey)) { + Region region = Region.US_EAST_1; + if (StringUtils.isNotBlank(regionName)) { + try { + region = Region.of(regionName); + } catch (IllegalArgumentException e) { + log.warn("Invalid aws_region: {}", regionName); + } + } + builder.region(region); + } else if (StringUtils.isNotBlank(regionName)) { + try { + builder.region(Region.of(regionName)); + } catch (IllegalArgumentException e) { + log.warn("Invalid aws_region: {}", regionName); + } + } + + String endpoint = s3BitStoreService.getEndpoint(); + if (StringUtils.isNotBlank(endpoint)) { + builder.endpointOverride(URI.create(endpoint)); + builder.serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(s3BitStoreService.getPathStyleAccessEnabled()) + .build()); + } + + return builder.build(); + } + + /** + * Whether the object is really there. A URL we cannot verify is never signed. + * + * The v1 SDK's `doesObjectExist` rethrew anything that was not a 404. There is no v2 equivalent, so + * this stays fail-closed - but a 403, an expired credential or a timeout is a misconfiguration the + * operator has to see, not a missing object, so it is logged at ERROR rather than DEBUG. + */ + private boolean doesObjectExist(String bucket, String key) { + try { + s3Client.headObject(r -> r.bucket(bucket).key(key)).join(); + return true; + } catch (CompletionException e) { + Throwable cause = e.getCause(); + if (cause instanceof NoSuchKeyException + || (cause instanceof S3Exception + && ((S3Exception) cause).statusCode() == HttpStatusCode.NOT_FOUND)) { + log.debug("headObject(bucket={}, key={}): object not found", bucket, key); + } else { + log.error("headObject(bucket={}, key={}) failed for a reason other than a missing object; " + + "refusing to sign a URL", bucket, key, cause); + } + return false; + } catch (Exception e) { + log.error("headObject(bucket={}, key={}) failed; refusing to sign a URL", bucket, key, e); + return false; + } + } + + @Override public String generatePresignedUrl(String bucket, String key, int expirationSeconds, String desiredFilename) { + return generatePresignedUrl(bucket, key, expirationSeconds, desiredFilename, null); + } + + @Override + public String generatePresignedUrl(String bucket, String key, int expirationSeconds, String desiredFilename, + String contentDispositionOverride) { if (desiredFilename == null) { log.error("Cannot generate presigned URL – desired filename is null"); throw new IllegalArgumentException("Desired filename cannot be null"); } - if (s3Client == null) { + if (s3Client == null || s3Presigner == null) { init(); } // Verify object exists before generating URL - if (!s3Client.doesObjectExist(bucket, key)) { + if (!doesObjectExist(bucket, key)) { log.error("Cannot generate presigned URL – object does not exist: bucket={}, key={}", bucket, key); throw new IllegalArgumentException("Requested S3 object does not exist"); } - Date expiration = Date.from(Instant.now().plusSeconds(expirationSeconds)); - // Create request - GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(bucket, key) - .withMethod(HttpMethod.GET) - .withExpiration(expiration); - // Add custom response header for filename - to download the file with the desired name - // Remove CRLF and quotes to prevent header injection - String safeName = desiredFilename.replaceAll("[\\r\\n\"]", "_"); - // RFC-5987: percent-encode UTF-8, e.g. filename*=UTF-8''%E2%82%ACrates.txt - String encoded = URLEncoder.encode(desiredFilename, StandardCharsets.UTF_8); - String contentDisposition = String.format( - "attachment; filename=\"%s\"; filename*=UTF-8''%s", - safeName, encoded); - - request.addRequestParameter("response-content-disposition", contentDisposition); + // Add custom response header for filename - to download the file with the desired name. + // The caller passes the disposition it would have served itself, so that redirecting to S3 does not + // silently turn an inline preview into a download; falling back to `attachment` is the safe default. + String contentDisposition = StringUtils.isNotBlank(contentDispositionOverride) + ? contentDispositionOverride + : ContentDispositionUtils.build(ContentDispositionUtils.ATTACHMENT, desiredFilename); + try { - return s3Client.generatePresignedUrl(request).toString(); + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .responseContentDisposition(contentDisposition) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofSeconds(expirationSeconds)) + .getObjectRequest(getObjectRequest) + .build(); + + return s3Presigner.presignGetObject(presignRequest).url().toString(); } catch (Exception e) { log.error("Failed to generate presigned URL for bucket: {}, key: {}", bucket, key, e); throw new RuntimeException("Failed to generate presigned URL", e); diff --git a/dspace-api/src/main/java/org/dspace/storage/bitstore/SyncS3BitStoreService.java b/dspace-api/src/main/java/org/dspace/storage/bitstore/SyncS3BitStoreService.java index ff1e2f86740..c9dfbc48328 100644 --- a/dspace-api/src/main/java/org/dspace/storage/bitstore/SyncS3BitStoreService.java +++ b/dspace-api/src/main/java/org/dspace/storage/bitstore/SyncS3BitStoreService.java @@ -18,14 +18,8 @@ import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletionException; -import com.amazonaws.AmazonClientException; -import com.amazonaws.services.s3.model.CompleteMultipartUploadRequest; -import com.amazonaws.services.s3.model.InitiateMultipartUploadRequest; -import com.amazonaws.services.s3.model.PartETag; -import com.amazonaws.services.s3.model.UploadPartRequest; -import com.amazonaws.services.s3.model.UploadPartResult; -import com.amazonaws.services.s3.transfer.Upload; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; @@ -34,6 +28,13 @@ import org.dspace.core.Utils; import org.dspace.services.ConfigurationService; import org.springframework.beans.factory.annotation.Autowired; +import software.amazon.awssdk.core.FileRequestBodyConfiguration; +import software.amazon.awssdk.core.async.AsyncRequestBody; +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.services.s3.model.ChecksumAlgorithm; +import software.amazon.awssdk.services.s3.model.CompletedMultipartUpload; +import software.amazon.awssdk.services.s3.model.CompletedPart; +import software.amazon.awssdk.services.s3.model.UploadPartResponse; /** * Override of the S3BitStoreService to store all the data also in the local assetstore. @@ -50,8 +51,12 @@ public class SyncS3BitStoreService extends S3BitStoreService { /** * The uploading file is divided into parts and each part is uploaded separately. The size of the part is 50 MB. + * + * Settable so the multipart path can actually be exercised by a test: with the 50 MB default and a small + * fixture the loop only ever runs once, which leaves the offset arithmetic and the last-part handling + * unverified. S3 requires every part except the last to be at least 5 MB. */ - private static final long UPLOAD_FILE_PART_SIZE = 50 * 1024 * 1024; // 50 MB + private long uploadPartSizeBytes = 50 * 1024 * 1024; // 50 MB /** * Upload large file by parts - check the checksum of every part @@ -124,13 +129,18 @@ public void put(Bitstream bitstream, InputStream in) throws IOException { // Create a new file in the assetstore if it does not exist createFileIfNotExist(localFile); - // Copy content from scratch file to local assetstore file - FileInputStream fisScratchFile = new FileInputStream(scratchFile); - FileOutputStream fosLocalFile = new FileOutputStream(localFile); - Utils.bufferedCopy(fisScratchFile, fosLocalFile); - fisScratchFile.close(); + // Copy content from scratch file to local assetstore file. Both streams have to be closed - + // leaking the output handle keeps the assetstore file locked and a later remove() silently + // fails to delete it. + try (FileInputStream fisScratchFile = new FileInputStream(scratchFile); + FileOutputStream fosLocalFile = new FileOutputStream(localFile)) { + Utils.bufferedCopy(fisScratchFile, fosLocalFile); + } } - } catch (AmazonClientException | IOException | InterruptedException e) { + } catch (CompletionException e) { + log.error("put(" + bitstream.getInternalId() + ", is)", e.getCause()); + throw new IOException(e.getCause()); + } catch (SdkException | IOException e) { log.error("put(" + bitstream.getInternalId() + ", is)", e); throw new IOException(e); } catch (NoSuchAlgorithmException nsae) { @@ -145,17 +155,11 @@ public void put(Bitstream bitstream, InputStream in) throws IOException { @Override public void remove(Bitstream bitstream) throws IOException { - String key = getFullKey(bitstream.getInternalId()); - try { - // Remove file from S3 - s3Service.deleteObject(getBucketName(), key); - if (syncEnabled) { - // Remove file from local assetstore - dsBitStoreService.remove(bitstream); - } - } catch (AmazonClientException e) { - log.error("remove(" + key + ")", e); - throw new IOException(e); + // Remove file from S3 - the parent already logs and wraps the failure in an IOException + super.remove(bitstream); + if (syncEnabled) { + // Remove file from local assetstore + dsBitStoreService.remove(bitstream); } } @@ -182,16 +186,21 @@ private void createFileIfNotExist(File localFile) throws IOException { } /** - * Upload a file fluently. The file is uploaded in a single request. + * Upload a file fluently. The CRT client splits it into parts on its own if it is large enough. * * @param key the bitstream's internalId * @param scratchFile the file to upload - * @throws InterruptedException if the S3 upload is interrupted */ - private void uploadFluently(String key, File scratchFile) throws InterruptedException { - Upload upload = tm.upload(getBucketName(), key, scratchFile); - - upload.waitForUploadResult(); + private void uploadFluently(String key, File scratchFile) { + // `assetstore.s3.s3ChecksumAlgorithm` would otherwise be dead configuration for the fork: + // bitstore.xml wires this class, which overrides put(), so the parent's putObject never runs. + ChecksumAlgorithm algorithm = getS3ChecksumAlgorithm(); + s3AsyncClient.putObject(r -> { + r.bucket(getBucketName()).key(key); + if (algorithm != null) { + r.checksumAlgorithm(algorithm); + } + }, AsyncRequestBody.fromFile(scratchFile)).join(); } /** @@ -212,45 +221,50 @@ private void uploadByParts(String key, File scratchFile) throws IOException { } // Initiate multipart upload - InitiateMultipartUploadRequest initiateRequest = new InitiateMultipartUploadRequest(getBucketName(), key); - String uploadId = this.s3Service.initiateMultipartUpload(initiateRequest).getUploadId(); + String uploadId = s3AsyncClient.createMultipartUpload(r -> r.bucket(getBucketName()).key(key)) + .join().uploadId(); // Create a list to hold the ETags for individual parts - List partETags = new ArrayList<>(); + List completedParts = new ArrayList<>(); try { // Upload parts - File file = new File(scratchFile.getPath()); - long fileLength = file.length(); + long fileLength = scratchFile.length(); long remainingBytes = fileLength; int partNumber = 1; while (remainingBytes > 0) { - long bytesToUpload = Math.min(UPLOAD_FILE_PART_SIZE, remainingBytes); + long bytesToUpload = Math.min(uploadPartSizeBytes, remainingBytes); + long offset = fileLength - remainingBytes; // Calculate the checksum for the part - String partChecksum = calculatePartChecksum(file, fileLength - remainingBytes, bytesToUpload, digest); - - UploadPartRequest uploadRequest = new UploadPartRequest() - .withBucketName(this.getBucketName()) - .withKey(key) - .withUploadId(uploadId) - .withPartNumber(partNumber) - .withFile(file) - .withFileOffset(fileLength - remainingBytes) - .withPartSize(bytesToUpload); - - // Upload the part - UploadPartResult uploadPartResponse = this.s3Service.uploadPart(uploadRequest); + String partChecksum = calculatePartChecksum(scratchFile, offset, bytesToUpload, digest); + + final int currentPartNumber = partNumber; + // A file-backed body, not a stream: the SDK re-reads it from the start when it retries a + // part. A non-resettable stream makes any transient S3 error permanent + // ("Request cannot be retried, because the request stream could not be reset"), which is + // what the v1 SDK avoided by taking the file plus an offset. + UploadPartResponse uploadPartResponse = s3AsyncClient.uploadPart( + r -> r.bucket(getBucketName()).key(key).uploadId(uploadId).partNumber(currentPartNumber), + AsyncRequestBody.fromFile(FileRequestBodyConfiguration.builder() + .path(scratchFile.toPath()) + .position(offset) + .numBytesToRead(bytesToUpload) + .build())).join(); // Collect the ETag for the part - partETags.add(uploadPartResponse.getPartETag()); - - // Compare checksums - local with ETag - if (!StringUtils.equals(uploadPartResponse.getETag(), partChecksum)) { + completedParts.add(CompletedPart.builder() + .partNumber(currentPartNumber) + .eTag(uploadPartResponse.eTag()) + .build()); + + // Compare checksums - local with ETag. Unlike the v1 SDK, v2 hands the ETag back quoted. + String eTag = StringUtils.strip(uploadPartResponse.eTag(), "\""); + if (!StringUtils.equals(eTag, partChecksum)) { String errorMessage = "Checksums do not match error: The locally computed checksum does " + "not match with the ETag from the UploadPartResult. Local checksum: " + partChecksum + - ", ETag: " + uploadPartResponse.getETag() + ", partNumber: " + partNumber; + ", ETag: " + eTag + ", partNumber: " + currentPartNumber; log.error(errorMessage); throw new IOException(errorMessage); } @@ -260,14 +274,44 @@ private void uploadByParts(String key, File scratchFile) throws IOException { } // Complete the multipart upload - CompleteMultipartUploadRequest completeRequest = new CompleteMultipartUploadRequest(this.getBucketName(), - key, uploadId, partETags); - this.s3Service.completeMultipartUpload(completeRequest); - } catch (AmazonClientException e) { - log.error("Cannot upload the file by parts because: ", e); + s3AsyncClient.completeMultipartUpload(r -> r.bucket(getBucketName()).key(key).uploadId(uploadId) + .multipartUpload(CompletedMultipartUpload.builder().parts(completedParts).build())).join(); + } catch (IOException e) { + abortQuietly(key, uploadId); + throw e; + } catch (SdkException | CompletionException e) { + // This used to be logged and swallowed, which let put() carry on and record a bitstream that + // S3 does not actually hold - silent data loss. Abort so the parts are not billed forever. + abortQuietly(key, uploadId); + throw new IOException("Multipart upload of " + key + " failed", e); } } + /** + * Abort a multipart upload, reporting but not rethrowing - the caller is already failing and the + * original cause is the one worth propagating. + * + * @param key the bitstream's internalId + * @param uploadId the multipart upload to abort + */ + private void abortQuietly(String key, String uploadId) { + try { + s3AsyncClient.abortMultipartUpload( + r -> r.bucket(getBucketName()).key(key).uploadId(uploadId)).join(); + } catch (SdkException | CompletionException e) { + log.error("Could not abort multipart upload " + uploadId + " for " + key + + "; its parts will remain until a lifecycle rule removes them", e); + } + } + + public long getUploadPartSizeBytes() { + return uploadPartSizeBytes; + } + + public void setUploadPartSizeBytes(long uploadPartSizeBytes) { + this.uploadPartSizeBytes = uploadPartSizeBytes; + } + /** * Calculate the checksum of the specified part of the file (Multipart upload) * @@ -282,8 +326,8 @@ public static String calculatePartChecksum(File file, long offset, long length, throws IOException { try (FileInputStream fis = new FileInputStream(file); DigestInputStream dis = new DigestInputStream(fis, digest)) { - // Skip to the specified offset - fis.skip(offset); + // Skip to the specified offset. `position` is exact, unlike `skip`, which may stop short. + fis.getChannel().position(offset); // Read the specified length IOUtils.copyLarge(dis, OutputStream.nullOutputStream(), 0, length); diff --git a/dspace-api/src/main/java/org/dspace/storage/bitstore/service/S3DirectDownloadService.java b/dspace-api/src/main/java/org/dspace/storage/bitstore/service/S3DirectDownloadService.java index bf7572d21a2..b989a8fdc69 100644 --- a/dspace-api/src/main/java/org/dspace/storage/bitstore/service/S3DirectDownloadService.java +++ b/dspace-api/src/main/java/org/dspace/storage/bitstore/service/S3DirectDownloadService.java @@ -26,4 +26,20 @@ public interface S3DirectDownloadService { */ String generatePresignedUrl(String bucket, String key, int expirationSeconds, String bitstreamName) throws UnsupportedEncodingException; + + /** + * Generate a presigned URL, serving it with a caller-supplied Content-Disposition. + * + * Without this the redirect always forced `attachment`, so turning on direct downloads silently + * disabled inline preview for every format in `webui.content_disposition_inline`. + * + * @param bucket The S3 bucket name + * @param key The bitstream path in the S3 bucket + * @param expirationSeconds The number of seconds until the URL expires + * @param bitstreamName The name of the bitstream, used when no override is given + * @param contentDispositionOverride The exact Content-Disposition to serve, or null for `attachment` + * @return A string containing the presigned URL for direct download access + */ + String generatePresignedUrl(String bucket, String key, int expirationSeconds, String bitstreamName, + String contentDispositionOverride) throws UnsupportedEncodingException; } diff --git a/dspace-api/src/main/java/org/dspace/text/filter/InitialArticleWord.java b/dspace-api/src/main/java/org/dspace/text/filter/InitialArticleWord.java deleted file mode 100644 index 167b201e0f7..00000000000 --- a/dspace-api/src/main/java/org/dspace/text/filter/InitialArticleWord.java +++ /dev/null @@ -1,172 +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.text.filter; - -/** - * Abstract class for implementing initial article word filters - * Allows you to create new classes with their own rules for mapping - * languages to article word lists. - * - * @author Graham Triggs - */ -public abstract class InitialArticleWord implements TextFilter { - /** - * When no language is passed, use null and let implementation decide what to do - */ - @Override - public String filter(String str) { - return filter(str, null); - } - - /** - * Do an initial definite/indefinite article filter on the passed string. - * On matching an initial word, can strip or move to the end, depending on the - * configuration of the implementing class. - * - * @param str The string to parse - * @param lang The language of the passed string - * @return String The filtered string - */ - @Override - public String filter(String str, String lang) { - // Get the list of article words for this language - String[] articleWordArr = getArticleWords(lang); - - // If we have an article word array, process the string - if (articleWordArr != null && articleWordArr.length > 0) { - String initialArticleWord = null; - int curPos = 0; - int initialStart = -1; - int initialEnd = -1; - - // Iterate through the characters until we find something significant, or hit the end - while (initialEnd < 0 && curPos < str.length()) { - // Have we found a significant character - if (Character.isLetterOrDigit(str.charAt(curPos))) { - // Mark this as the cut point for the initial word - initialStart = curPos; - - // Loop through the article words looking for a match - for (int idx = 0; initialEnd < 0 && idx < articleWordArr.length; idx++) { - // Extract a fragment from the string to test - // Must be same length as the article word - if (idx > 1 && initialArticleWord != null) { - // Only need to do so if we haven't already got one - // of the right length - if (initialArticleWord.length() != articleWordArr[idx].length()) { - initialArticleWord = extractText(str, curPos, articleWordArr[idx].length()); - } - } else { - initialArticleWord = extractText(str, curPos, articleWordArr[idx].length()); - } - - // Does the fragment match an article word? - if (initialArticleWord != null && initialArticleWord.equalsIgnoreCase(articleWordArr[idx])) { - // Check to see if the next character in the source - // is a whitespace - boolean isNextWhitespace = Character.isWhitespace( - str.charAt(curPos + articleWordArr[idx].length()) - ); - - // Check to see if the last character of the article word is a letter or digit - boolean endsLetterOrDigit = Character - .isLetterOrDigit(initialArticleWord.charAt(initialArticleWord.length() - 1)); - - // If the last character of the article word is a letter or digit, - // then it must be followed by whitespace, if not, it can be anything - // Setting endPos signifies that we have found an article word - if (endsLetterOrDigit && isNextWhitespace) { - initialEnd = curPos + initialArticleWord.length(); - } else if (!endsLetterOrDigit) { - initialEnd = curPos + initialArticleWord.length(); - } - } - } - - // Quit the loop, as we have a significant character - break; - } - - // Keep going - curPos++; - } - - // If endPos is positive, then we've found an article word - if (initialEnd > 0) { - // Find a cut point in the source string, removing any whitespace after the article word - int cutPos = initialEnd; - while (cutPos < str.length() && Character.isWhitespace(str.charAt(cutPos))) { - cutPos++; - } - - // Are we stripping the article word? - if (stripInitialArticle) { - // Yes, simply return everything after the cut - return str.substring(cutPos); - } else { - // No - move the initial article word to the end - return new StringBuilder(str.substring(cutPos)) - .append(wordSeparator) - .append(str.substring(initialStart, initialEnd)) - .toString(); - } - } - } - - // Didn't do any processing, or didn't find an initial article word - // Return the original string - return str; - } - - protected InitialArticleWord(boolean stripWord) { - this.wordSeparator = ", "; - stripInitialArticle = stripWord; - } - - protected InitialArticleWord() { - this.wordSeparator = ", "; - stripInitialArticle = false; - } - - /** - * Abstract method to get the list of words to use in the initial word filter - * - * @param lang The language to retrieve article words for - * @return An array of definite/indefinite article words - */ - protected abstract String[] getArticleWords(String lang); - // Separator to use when appending article to end - private final String wordSeparator; - - // Flag to signify initial article word should be removed - // If false, then the initial article word is appended to the end - private boolean stripInitialArticle = false; - - /** - * Helper method to extract text from a string. - * Ensures that there is significant data (ie. non-whitespace) - * after the segment requested. - * - * @param str - * @param pos - * @param len - * @return - */ - private String extractText(String str, int pos, int len) { - int testPos = pos + len; - while (testPos < str.length() && Character.isWhitespace(str.charAt(testPos))) { - testPos++; - } - - if (testPos < str.length()) { - return str.substring(pos, pos + len); - } - - return null; - } -} diff --git a/dspace-api/src/main/java/org/dspace/text/filter/Language.java b/dspace-api/src/main/java/org/dspace/text/filter/Language.java deleted file mode 100644 index 9be68d2ddfb..00000000000 --- a/dspace-api/src/main/java/org/dspace/text/filter/Language.java +++ /dev/null @@ -1,142 +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.text.filter; - -import java.util.HashMap; -import java.util.Map; - -/** - * Define languages - both as IANA and ISO639-2 codes - * - * @author Graham Triggs - */ -public class Language { - public final String IANA; - public final String ISO639_1; - public final String ISO639_2; - - public static final Language AFRIKAANS = Language.create("af", "af", "afr"); - public static final Language ALBANIAN = Language.create("sq", "sq", "alb"); - public static final Language ARABIC = Language.create("ar", "ar", "ara"); - public static final Language BALUCHI = Language.create("bal", "", "bal"); - public static final Language BASQUE = Language.create("eu", "", "baq"); - public static final Language BRAHUI = Language.create("", "", ""); - public static final Language CATALAN = Language.create("ca", "ca", "cat"); - public static final Language CLASSICAL_GREEK = Language.create("grc", "", "grc"); - public static final Language DANISH = Language.create("da", "da", "dan"); - public static final Language DUTCH = Language.create("nl", "ni", "dut"); - public static final Language ENGLISH = Language.create("en", "en", "eng"); - public static final Language ESPERANTO = Language.create("eo", "eo", "epo"); - public static final Language FRENCH = Language.create("fr", "fr", "fre"); - public static final Language FRISIAN = Language.create("fy", "fy", "fri"); - public static final Language GALICIAN = Language.create("gl", "gl", "glg"); - public static final Language GERMAN = Language.create("de", "de", "ger"); - public static final Language GREEK = Language.create("el", "el", "gre"); - public static final Language HAWAIIAN = Language.create("haw", "", "haw"); - public static final Language HEBREW = Language.create("he", "he", "heb"); - public static final Language HUNGARIAN = Language.create("hu", "hu", "hun"); - public static final Language ICELANDIC = Language.create("is", "is", "ice"); - public static final Language IRISH = Language.create("ga", "ga", "gle"); - public static final Language ITALIAN = Language.create("it", "it", "ita"); - public static final Language MALAGASY = Language.create("mg", "mg", "mlg"); - public static final Language MALTESE = Language.create("mt", "mt", "mlt"); - public static final Language NEAPOLITAN_ITALIAN = Language.create("nap", "", "nap"); - public static final Language NORWEGIAN = Language.create("no", "no", "nor"); - public static final Language PORTUGUESE = Language.create("pt", "pt", "por"); - public static final Language PANJABI = Language.create("pa", "pa", "pan"); - public static final Language PERSIAN = Language.create("fa", "fa", "per"); - public static final Language PROVENCAL = Language.create("pro", "", "pro"); - public static final Language PROVENCAL_OCCITAN = Language.create("oc", "oc", "oci"); - public static final Language ROMANIAN = Language.create("ro", "ro", "rum"); - public static final Language SCOTS = Language.create("sco", "", "sco"); - public static final Language SCOTTISH_GAELIC = Language.create("gd", "gd", "gae"); - public static final Language SHETLAND_ENGLISH = Language.create("", "", ""); - public static final Language SPANISH = Language.create("es", "es", "spa"); - public static final Language SWEDISH = Language.create("sv", "sv", "swe"); - public static final Language TAGALOG = Language.create("tl", "tl", "tgl"); - public static final Language TURKISH = Language.create("tr", "tr", "tur"); - public static final Language URDU = Language.create("ur", "ur", "urd"); - public static final Language WALLOON = Language.create("wa", "wa", "wln"); - public static final Language WELSH = Language.create("cy", "cy", "wel"); - public static final Language YIDDISH = Language.create("yi", "yi", "yid"); - - public static Language getLanguage(String lang) { - return LanguageMaps.getLanguage(lang); - } - - public static Language getLanguageForIANA(String iana) { - return LanguageMaps.getLanguageForIANA(iana); - } - - public static Language getLanguageForISO639_2(String iso) { - return LanguageMaps.getLanguageForISO639_2(iso); - } - - private static synchronized Language create(String iana, String iso639_1, String iso639_2) { - Language lang = LanguageMaps.getLanguageForIANA(iana); - - lang = (lang != null ? lang : LanguageMaps.getLanguageForISO639_1(iso639_1)); - lang = (lang != null ? lang : LanguageMaps.getLanguageForISO639_2(iso639_2)); - - return (lang != null ? lang : new Language(iana, iso639_1, iso639_2)); - } - - private static class LanguageMaps { - private static final Map langMapIANA = new HashMap(); - private static final Map langMapISO639_1 = new HashMap(); - private static final Map langMapISO639_2 = new HashMap(); - - static void add(Language l) { - if (l.IANA != null && l.IANA.length() > 0 && !langMapIANA.containsKey(l.IANA)) { - langMapIANA.put(l.IANA, l); - } - - if (l.ISO639_1 != null && l.ISO639_1.length() > 0 && !langMapISO639_1.containsKey(l.ISO639_1)) { - langMapISO639_1.put(l.ISO639_1, l); - } - - if (l.ISO639_2 != null && l.ISO639_2.length() > 0 && !langMapISO639_2.containsKey(l.ISO639_2)) { - langMapISO639_2.put(l.ISO639_2, l); - } - } - - public static Language getLanguage(String lang) { - if (langMapIANA.containsKey(lang)) { - return langMapIANA.get(lang); - } - - return langMapISO639_2.get(lang); - } - - public static Language getLanguageForIANA(String iana) { - return langMapIANA.get(iana); - } - - public static Language getLanguageForISO639_1(String iso) { - return langMapISO639_1.get(iso); - } - - public static Language getLanguageForISO639_2(String iso) { - return langMapISO639_2.get(iso); - } - } - - private Language(String iana, String iso639_1, String iso639_2) { - IANA = iana; - ISO639_1 = iso639_1; - ISO639_2 = iso639_2; - - LanguageMaps.add(this); - } - - private Language() { - IANA = null; - ISO639_1 = null; - ISO639_2 = null; - } -} diff --git a/dspace-api/src/main/java/org/dspace/text/filter/MARC21InitialArticleWord.java b/dspace-api/src/main/java/org/dspace/text/filter/MARC21InitialArticleWord.java deleted file mode 100644 index c82b9ccfcf8..00000000000 --- a/dspace-api/src/main/java/org/dspace/text/filter/MARC21InitialArticleWord.java +++ /dev/null @@ -1,329 +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.text.filter; - -import java.io.Serializable; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import org.apache.commons.lang3.ArrayUtils; -import org.apache.commons.lang3.StringUtils; -import org.dspace.services.factory.DSpaceServicesFactory; - -/** - * Implements MARC 21 standards to disregard initial - * definite or indefinite article in sorting. - * - * Note: This only works for languages defined with IANA code entries. - * - * @author Graham Triggs - */ -public class MARC21InitialArticleWord extends InitialArticleWord { - public MARC21InitialArticleWord() { - // Default behaviour is to strip the initial word completely - super(true); - } - - public MARC21InitialArticleWord(boolean stripWord) { - super(stripWord); - } - - /** - * Return the list of definite and indefinite article codes - * for this language. - */ - @Override - protected String[] getArticleWords(String lang) { - // No language - no words - if (StringUtils.isEmpty(lang)) { - return defaultWords; - } - - Language l = Language.getLanguage(lang); - - // Is the language in our map? - if (l != null && ianaArticleMap.containsKey(l.IANA)) { - // Get the list of words for this language - ArticlesForLang articles = ianaArticleMap.get(l.IANA); - - if (articles != null) { - return articles.words; - } - } - - return null; - } - - // Mapping of IANA codes to article word lists - private static Map ianaArticleMap = new HashMap(); - - private static String[] defaultWords = null; - - // Static initialisation - convert word -> languages map - // into language -> words map - static { - /* Define a mapping for article words to the languages that have them. - * Take from: http://www.loc.gov/marc/bibliographic/bdapp-e.html - */ - Object[][] articleWordArray = { - {"a", Language.ENGLISH, Language.GALICIAN, Language.HUNGARIAN, Language.PORTUGUESE, Language.ROMANIAN, - Language.SCOTS, Language.YIDDISH}, - {"a'", Language.SCOTTISH_GAELIC}, - {"al", Language.ROMANIAN}, - {"al-", Language.ARABIC, Language.BALUCHI, Language.BRAHUI, Language.PANJABI, Language.PERSIAN, - Language.TURKISH, Language.URDU}, - {"am", Language.SCOTTISH_GAELIC}, - {"an", Language.ENGLISH, Language.IRISH, Language.SCOTS, Language.SCOTTISH_GAELIC, Language.YIDDISH}, - {"an t-", Language.IRISH, Language.SCOTTISH_GAELIC}, - {"ane", Language.SCOTS}, - {"ang", Language.TAGALOG}, - {"ang mga", Language.TAGALOG}, - {"as", Language.GALICIAN, Language.PORTUGUESE}, - {"az", Language.HUNGARIAN}, - {"bat", Language.BASQUE}, - {"bir", Language.TURKISH}, - {"d'", Language.ENGLISH}, - {"da", Language.SHETLAND_ENGLISH}, - {"das", Language.GERMAN}, - {"de", Language.DANISH, Language.DUTCH, Language.ENGLISH, Language.FRISIAN, Language.NORWEGIAN, - Language.SWEDISH}, - {"dei", Language.NORWEGIAN}, - {"dem", Language.GERMAN}, - {"den", Language.DANISH, Language.GERMAN, Language.NORWEGIAN, Language.SWEDISH}, - {"der", Language.GERMAN, Language.YIDDISH}, - {"des", Language.GERMAN, Language.WALLOON}, - {"det", Language.DANISH, Language.NORWEGIAN, Language.SWEDISH}, - {"di", Language.YIDDISH}, - {"die", Language.AFRIKAANS, Language.GERMAN, Language.YIDDISH}, - {"dos", Language.YIDDISH}, - {"e", Language.NORWEGIAN}, - {"e", Language.FRISIAN}, // should be 'e - leading apostrophes are ignored - {"een", Language.DUTCH}, - {"eene", Language.DUTCH}, - {"egy", Language.HUNGARIAN}, - {"ei", Language.NORWEGIAN}, - {"ein", Language.GERMAN, Language.NORWEGIAN, Language.WALLOON}, - {"eine", Language.GERMAN}, - {"einem", Language.GERMAN}, - {"einen", Language.GERMAN}, - {"einer", Language.GERMAN}, - {"eines", Language.GERMAN}, - {"eit", Language.NORWEGIAN}, - {"el", Language.CATALAN, Language.SPANISH}, - {"el-", Language.ARABIC}, - {"els", Language.CATALAN}, - {"en", Language.CATALAN, Language.DANISH, Language.NORWEGIAN, Language.SWEDISH}, - {"enne", Language.WALLOON}, - {"et", Language.DANISH, Language.NORWEGIAN}, - {"ett", Language.SWEDISH}, - {"eyn", Language.YIDDISH}, - {"eyne", Language.YIDDISH}, - {"gl'", Language.ITALIAN}, - {"gli", Language.PROVENCAL}, - {"ha-", Language.HEBREW}, - {"hai", Language.CLASSICAL_GREEK, Language.GREEK}, - {"he", Language.HAWAIIAN}, - {"h\u0113", Language.CLASSICAL_GREEK, Language.GREEK}, // e macron - {"he-", Language.HEBREW}, - {"heis", Language.GREEK}, - {"hen", Language.GREEK}, - {"hena", Language.GREEK}, - {"henas", Language.GREEK}, - {"het", Language.DUTCH}, - {"hin", Language.ICELANDIC}, - {"hina", Language.ICELANDIC}, - {"hinar", Language.ICELANDIC}, - {"hinir", Language.ICELANDIC}, - {"hinn", Language.ICELANDIC}, - {"hinna", Language.ICELANDIC}, - {"hinnar", Language.ICELANDIC}, - {"hinni", Language.ICELANDIC}, - {"hins", Language.ICELANDIC}, - {"hinu", Language.ICELANDIC}, - {"hinum", Language.ICELANDIC}, - {"hi\u01d2", Language.ICELANDIC}, - {"ho", Language.CLASSICAL_GREEK, Language.GREEK}, - {"hoi", Language.CLASSICAL_GREEK, Language.GREEK}, - {"i", Language.ITALIAN}, - {"ih'", Language.PROVENCAL}, - {"il", Language.ITALIAN, Language.PROVENCAL_OCCITAN}, - {"il-", Language.MALTESE}, - {"in", Language.FRISIAN}, - {"it", Language.FRISIAN}, - {"ka", Language.HAWAIIAN}, - {"ke", Language.HAWAIIAN}, - {"l'", Language.CATALAN, Language.FRENCH, Language.ITALIAN, Language.PROVENCAL_OCCITAN, Language.WALLOON}, - {"l-", Language.MALTESE}, - {"la", Language.CATALAN, Language.ESPERANTO, Language.FRENCH, Language.ITALIAN, Language.PROVENCAL_OCCITAN, - Language.SPANISH}, - {"las", Language.PROVENCAL_OCCITAN, Language.SPANISH}, - {"le", Language.FRENCH, Language.ITALIAN, Language.PROVENCAL_OCCITAN}, - {"les", Language.CATALAN, Language.FRENCH, Language.PROVENCAL_OCCITAN, Language.WALLOON}, - {"lh", Language.PROVENCAL_OCCITAN}, - {"lhi", Language.PROVENCAL_OCCITAN}, - {"li", Language.PROVENCAL_OCCITAN}, - {"lis", Language.PROVENCAL_OCCITAN}, - {"lo", Language.ITALIAN, Language.PROVENCAL_OCCITAN, Language.SPANISH}, - {"los", Language.PROVENCAL_OCCITAN, Language.SPANISH}, - {"lou", Language.PROVENCAL_OCCITAN}, - {"lu", Language.PROVENCAL_OCCITAN}, - {"mga", Language.TAGALOG}, - {"m\u0303ga", Language.TAGALOG}, - {"mia", Language.GREEK}, - {"n", Language.AFRIKAANS, Language.DUTCH, Language.FRISIAN}, // should be 'n - leading - // apostrophes are ignored - {"na", Language.HAWAIIAN, Language.IRISH, Language.SCOTTISH_GAELIC}, - {"na h-", Language.IRISH, Language.SCOTTISH_GAELIC}, - {"nje", Language.ALBANIAN}, - {"ny", Language.MALAGASY}, - {"o", Language.NEAPOLITAN_ITALIAN}, // should be 'o - leading apostrophes are ignored - {"o", Language.GALICIAN, Language.HAWAIIAN, Language.PORTUGUESE, Language.ROMANIAN}, - {"os", Language.PORTUGUESE}, - {"r", Language.ICELANDIC}, // should be 'r - leading apostrophes are ignored - {"s", Language.GERMAN}, // should be 's - leading apostrophes are ignored - {"sa", Language.TAGALOG}, - {"sa mga", Language.TAGALOG}, - {"si", Language.TAGALOG}, - {"sin\u00e1", Language.TAGALOG}, - {"t", Language.DUTCH, Language.FRISIAN}, // should be 't - leading apostrophes are ignored - {"ta", Language.CLASSICAL_GREEK, Language.GREEK}, - {"tais", Language.CLASSICAL_GREEK}, - {"tas", Language.CLASSICAL_GREEK}, - {"t\u0113", Language.CLASSICAL_GREEK}, // e macron - {"t\u0113n", Language.CLASSICAL_GREEK, Language.GREEK}, // e macron - {"t\u0113s", Language.CLASSICAL_GREEK, Language.GREEK}, // e macron - {"the", Language.ENGLISH}, - {"t\u014d", Language.CLASSICAL_GREEK, Language.GREEK}, // o macron - {"tois", Language.CLASSICAL_GREEK}, - {"t\u014dn", Language.CLASSICAL_GREEK, Language.GREEK}, // o macron - {"tou", Language.CLASSICAL_GREEK, Language.GREEK}, - {"um", Language.PORTUGUESE}, - {"uma", Language.PORTUGUESE}, - {"un", Language.CATALAN, Language.FRENCH, Language.ITALIAN, Language.PROVENCAL_OCCITAN, Language.ROMANIAN, - Language.SPANISH}, - {"un'", Language.ITALIAN}, - {"una", Language.CATALAN, Language.ITALIAN, Language.PROVENCAL_OCCITAN, Language.SPANISH}, - {"une", Language.FRENCH}, - {"unei", Language.ROMANIAN}, - {"unha", Language.GALICIAN}, - {"uno", Language.ITALIAN, Language.PROVENCAL_OCCITAN}, - {"uns", Language.PROVENCAL_OCCITAN}, - {"unui", Language.ROMANIAN}, - {"us", Language.PROVENCAL_OCCITAN}, - {"y", Language.WELSH}, - {"ye", Language.ENGLISH}, - {"yr", Language.WELSH} - }; - - // Initialize the lang -> article map - ianaArticleMap = new HashMap(); - - int wordIdx = 0; - int langIdx = 0; - - // Iterate through word/language array - // Generate temporary language map - Map> langWordMap = new HashMap>(); - for (wordIdx = 0; wordIdx < articleWordArray.length; wordIdx++) { - for (langIdx = 1; langIdx < articleWordArray[wordIdx].length; langIdx++) { - Language lang = (Language) articleWordArray[wordIdx][langIdx]; - - if (lang != null && lang.IANA.length() > 0) { - List words = langWordMap.get(lang); - - if (words == null) { - words = new ArrayList(); - langWordMap.put(lang, words); - } - - // Add language to list if we haven't done so already - if (!words.contains(articleWordArray[wordIdx][0])) { - words.add((String) articleWordArray[wordIdx][0]); - } - } - } - } - - // Iterate through languages - for (Map.Entry> langToWord : langWordMap.entrySet()) { - Language lang = langToWord.getKey(); - List wordList = langToWord.getValue(); - - // Convert the list into an array of strings - String[] words = new String[wordList.size()]; - - for (int idx = 0; idx < wordList.size(); idx++) { - words[idx] = wordList.get(idx); - } - - // Sort the array into length order - longest to shortest - // This ensures maximal matching on the article words - Arrays.sort(words, new MARC21InitialArticleWord.InverseLengthComparator()); - - // Add language/article entry to map - ianaArticleMap.put(lang.IANA, new MARC21InitialArticleWord.ArticlesForLang(lang, words)); - } - - // Setup default stop words for null languages - String[] defaultLangs = DSpaceServicesFactory.getInstance().getConfigurationService() - .getArrayProperty("marc21wordfilter.defaultlang"); - if (ArrayUtils.isNotEmpty(defaultLangs)) { - int wordCount = 0; - ArticlesForLang[] afl = new ArticlesForLang[defaultLangs.length]; - - for (int idx = 0; idx < afl.length; idx++) { - Language l = Language.getLanguage(defaultLangs[idx]); - if (l != null && ianaArticleMap.containsKey(l.IANA)) { - afl[idx] = ianaArticleMap.get(l.IANA); - if (afl[idx] != null) { - wordCount += afl[idx].words.length; - } - } - } - - if (wordCount > 0) { - int destPos = 0; - defaultWords = new String[wordCount]; - for (int idx = 0; idx < afl.length; idx++) { - if (afl[idx] != null) { - System.arraycopy(afl[idx].words, 0, defaultWords, destPos, afl[idx].words.length); - destPos += afl[idx].words.length; - } - } - } - } - } - - // Wrapper class for inserting word arrays into a map - private static class ArticlesForLang { - final Language lang; - final String[] words; - - ArticlesForLang(Language lang, String[] words) { - this.lang = lang; - this.words = (String[]) ArrayUtils.clone(words); - } - } - - // Compare strings according to their length - longest to shortest - private static class InverseLengthComparator implements Comparator, Serializable { - @Override - public int compare(Object arg0, Object arg1) { - return ((String) arg1).length() - ((String) arg0).length(); - } - - ; - - } - - ; -} diff --git a/dspace-api/src/main/java/org/dspace/text/filter/StandardInitialArticleWord.java b/dspace-api/src/main/java/org/dspace/text/filter/StandardInitialArticleWord.java deleted file mode 100644 index ade72b150f5..00000000000 --- a/dspace-api/src/main/java/org/dspace/text/filter/StandardInitialArticleWord.java +++ /dev/null @@ -1,30 +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.text.filter; - -/** - * Implements existing DSpace initial article word behaviour - * - * Note: This only works for languages defined with ISO code entries. - * - * @author Graham Triggs - */ -public class StandardInitialArticleWord extends InitialArticleWord { - private static final String[] articleWords = {"the", "an", "a"}; - - @Override - protected String[] getArticleWords(String lang) { - if (lang != null && lang.startsWith("en")) { - return articleWords; - } - - return null; - } - -} - diff --git a/dspace-api/src/main/java/org/dspace/util/ContentDispositionUtils.java b/dspace-api/src/main/java/org/dspace/util/ContentDispositionUtils.java new file mode 100644 index 00000000000..91487175b4d --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/util/ContentDispositionUtils.java @@ -0,0 +1,57 @@ +/** + * 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.util; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * Builds a `Content-Disposition` header value from a bitstream name. + * + * The fork had two independent implementations of this, one of them wrong: it escaped `"` but not `\`, + * so a bitstream named `evil\` terminated the quoted string early and swallowed the `filename*` + * parameter; and it used {@link URLEncoder} directly, which encodes a space as `+`. RFC 8187 treats + * `+` as a literal character, so `my report.pdf` arrived as `my+report.pdf`. + * + * @author Milan Majchrak (dspace at dataquest.sk) + */ +public final class ContentDispositionUtils { + + public static final String ATTACHMENT = "attachment"; + public static final String INLINE = "inline"; + + private ContentDispositionUtils() { + } + + /** + * Build a `Content-Disposition` value carrying both the RFC 6266 ASCII fallback and the RFC 8187 + * percent-encoded UTF-8 name. + * + * @param disposition `attachment` or `inline` + * @param name the bitstream name; must not be null + * @return the header value + */ + public static String build(String disposition, String name) { + if (name == null) { + throw new IllegalArgumentException("Bitstream name cannot be null"); + } + + // RFC 8187 percent-encoding for filename*. URLEncoder is form-encoding, so `+` has to be + // converted back to `%20` - a literal `+` in a filename is already encoded as `%2B` by then. + String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8) + .replace("+", "%20"); + + // ASCII fallback for clients that ignore filename*. Non-ASCII becomes `_`; backslash and quote + // are escaped, in that order, so the quoted-string cannot be terminated early. + String asciiFallback = name.replaceAll("[^\\x20-\\x7E]", "_") + .replace("\\", "\\\\") + .replace("\"", "\\\""); + + return String.format("%s; filename=\"%s\"; filename*=UTF-8''%s", disposition, asciiFallback, encoded); + } +} diff --git a/dspace-api/src/main/java/org/dspace/util/DateMathParser.java b/dspace-api/src/main/java/org/dspace/util/DateMathParser.java index 9ff252e8ce3..13f9216c9bd 100644 --- a/dspace-api/src/main/java/org/dspace/util/DateMathParser.java +++ b/dspace-api/src/main/java/org/dspace/util/DateMathParser.java @@ -13,6 +13,7 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.time.ZoneId; +import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatterBuilder; @@ -107,7 +108,7 @@ public class DateMathParser { private static final Logger LOG = LogManager.getLogger(); - public static final TimeZone UTC = TimeZone.getTimeZone("UTC"); + public static final TimeZone UTC = TimeZone.getTimeZone(ZoneOffset.UTC); /** * Default TimeZone for DateMath rounding (UTC) diff --git a/dspace-api/src/main/resources/Messages.properties b/dspace-api/src/main/resources/Messages.properties index efbbeedde05..9d15bd0621a 100644 --- a/dspace-api/src/main/resources/Messages.properties +++ b/dspace-api/src/main/resources/Messages.properties @@ -72,20 +72,20 @@ org.dspace.checker.ResultsLogger.store-number org.dspace.checker.ResultsLogger.to-be-processed = To be processed org.dspace.checker.ResultsLogger.user-format-description = User format description org.dspace.checker.SimpleReporterImpl.bitstream-id = Bitstream Id -org.dspace.checker.SimpleReporterImpl.bitstream-not-found-report = The following is a BITSTREAM NOT FOUND report for -org.dspace.checker.SimpleReporterImpl.bitstream-will-no-longer-be-processed = The following is a BITSTREAM WILL NO LONGER BE PROCESSED report for +org.dspace.checker.SimpleReporterImpl.bitstream-not-found-report = The following is a BITSTREAM NOT FOUND report from +org.dspace.checker.SimpleReporterImpl.bitstream-will-no-longer-be-processed = The following is a BITSTREAM WILL NO LONGER BE PROCESSED report from org.dspace.checker.SimpleReporterImpl.check-id = Check Id org.dspace.checker.SimpleReporterImpl.checksum = Checksum org.dspace.checker.SimpleReporterImpl.checksum-algorithm = Checksum Algorithm org.dspace.checker.SimpleReporterImpl.checksum-calculated = Checksum Calculated -org.dspace.checker.SimpleReporterImpl.checksum-did-not-match = The following is a CHECKSUM DID NOT MATCH report for +org.dspace.checker.SimpleReporterImpl.checksum-did-not-match = The following is a CHECKSUM DID NOT MATCH report from org.dspace.checker.SimpleReporterImpl.checksum-expected = Checksum Expected org.dspace.checker.SimpleReporterImpl.date-range-to = to org.dspace.checker.SimpleReporterImpl.deleted = Deleted -org.dspace.checker.SimpleReporterImpl.deleted-bitstream-intro = The following is a BITSTREAM SET DELETED report for +org.dspace.checker.SimpleReporterImpl.deleted-bitstream-intro = The following is a BITSTREAM SET DELETED report from org.dspace.checker.SimpleReporterImpl.description = Description org.dspace.checker.SimpleReporterImpl.format-id = Format Id -org.dspace.checker.SimpleReporterImpl.howto-add-unchecked-bitstreams = To add these bitstreams to be checked run the checksum checker with the -u option +org.dspace.checker.SimpleReporterImpl.howto-add-unchecked-bitstreams = To add these bitstreams to be checked run the checksum checker again org.dspace.checker.SimpleReporterImpl.internal-id = Internal Id org.dspace.checker.SimpleReporterImpl.name = Name org.dspace.checker.SimpleReporterImpl.no-bitstreams-changed = There were no bitstreams found with changed checksums diff --git a/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/h2/V7.6_2026.03.25__entity_types_caching.sql b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/h2/V7.6_2026.03.25__entity_types_caching.sql new file mode 100644 index 00000000000..1b6db9f5284 --- /dev/null +++ b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/h2/V7.6_2026.03.25__entity_types_caching.sql @@ -0,0 +1,11 @@ +-- +-- 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/ +-- + +-- H2 does not support upper and hence the migration differs slightly from the postgres version +-- In a test environment this will not make a meaningful impact +CREATE INDEX entity_type_label_upper_idx ON entity_type (label); diff --git a/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/postgres/V7.6_2025.10.29__Fix-request-items-with-deleted-bitstreams.sql b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/postgres/V7.6_2025.10.29__Fix-request-items-with-deleted-bitstreams.sql new file mode 100644 index 00000000000..4f0c54c975c --- /dev/null +++ b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/postgres/V7.6_2025.10.29__Fix-request-items-with-deleted-bitstreams.sql @@ -0,0 +1,14 @@ +-- +-- 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/ +-- + +DELETE +FROM requestitem +WHERE bitstream_id IN + (SELECT bs.uuid + FROM bitstream AS bs + WHERE bs.deleted IS TRUE) diff --git a/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/postgres/V7.6_2026.03.25__entity_types_caching.sql b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/postgres/V7.6_2026.03.25__entity_types_caching.sql new file mode 100644 index 00000000000..2a1510ff6f0 --- /dev/null +++ b/dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/postgres/V7.6_2026.03.25__entity_types_caching.sql @@ -0,0 +1,9 @@ +-- +-- 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/ +-- + +CREATE INDEX entity_type_label_upper_idx ON entity_type (UPPER(label)); 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 316f8f1bc12..29a2d9a0294 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 @@ -56,6 +56,7 @@ + diff --git a/dspace-api/src/test/data/dspaceFolder/config/local.cfg b/dspace-api/src/test/data/dspaceFolder/config/local.cfg index b714d516d59..1fd5e527c84 100644 --- a/dspace-api/src/test/data/dspaceFolder/config/local.cfg +++ b/dspace-api/src/test/data/dspaceFolder/config/local.cfg @@ -335,3 +335,9 @@ s3.download.direct.enabled = false user.registration = true config.admin.updateable.files = + +# Keep the *vanilla* inline-preview allowlist in the test environment. The CLARIN production +# override in clarin-dspace.cfg adds text/plain, text/csv, tiff and more audio formats, which +# would break the upstream BitstreamRestControllerIT#checkContentDispositionOfFormats test that +# asserts the vanilla default (those formats download). local.cfg wins over clarin-dspace.cfg. +webui.content_disposition_inline = application/pdf, image/gif, image/jpeg, image/png, audio/mpeg, video/mpeg, video/mp4 diff --git a/dspace-api/src/test/data/dspaceFolder/config/spring/api/workflow-actions.xml b/dspace-api/src/test/data/dspaceFolder/config/spring/api/workflow-actions.xml index 0d074362279..a7c725c524f 100644 --- a/dspace-api/src/test/data/dspaceFolder/config/spring/api/workflow-actions.xml +++ b/dspace-api/src/test/data/dspaceFolder/config/spring/api/workflow-actions.xml @@ -23,7 +23,6 @@ - @@ -46,7 +45,6 @@ - @@ -66,21 +64,14 @@ - - - - - - - + - - + diff --git a/dspace-api/src/test/java/org/dspace/AbstractDSpaceIntegrationTest.java b/dspace-api/src/test/java/org/dspace/AbstractDSpaceIntegrationTest.java index 791fdbc66ab..2822cdcf601 100644 --- a/dspace-api/src/test/java/org/dspace/AbstractDSpaceIntegrationTest.java +++ b/dspace-api/src/test/java/org/dspace/AbstractDSpaceIntegrationTest.java @@ -12,6 +12,7 @@ import java.io.IOException; import java.net.URL; import java.sql.SQLException; +import java.time.ZoneOffset; import java.util.Properties; import java.util.TimeZone; @@ -73,8 +74,10 @@ public static void initTestEnvironment() { //Stops System.exit(0) throws exception instead of exitting System.setSecurityManager(new NoExitSecurityManager()); - //set a standard time zone for the tests - TimeZone.setDefault(TimeZone.getTimeZone("Europe/Dublin")); + // All tests should assume UTC timezone by default (unless overridden in the test itself) + // This ensures that Spring doesn't attempt to change the timezone of dates that are read from the + // database (via Hibernate). We store all dates in the database as UTC. + TimeZone.setDefault(TimeZone.getTimeZone(ZoneOffset.UTC)); //load the properties of the tests testProps = new Properties(); diff --git a/dspace-api/src/test/java/org/dspace/AbstractDSpaceTest.java b/dspace-api/src/test/java/org/dspace/AbstractDSpaceTest.java index 136af83f076..4452955a3b6 100644 --- a/dspace-api/src/test/java/org/dspace/AbstractDSpaceTest.java +++ b/dspace-api/src/test/java/org/dspace/AbstractDSpaceTest.java @@ -12,6 +12,7 @@ import java.io.IOException; import java.net.URL; import java.sql.SQLException; +import java.time.ZoneOffset; import java.util.Properties; import java.util.TimeZone; @@ -82,8 +83,10 @@ protected AbstractDSpaceTest() { } @BeforeClass public static void initKernel() { try { - //set a standard time zone for the tests - TimeZone.setDefault(TimeZone.getTimeZone("Europe/Dublin")); + // All tests should assume UTC timezone by default (unless overridden in the test itself) + // This ensures that Spring doesn't attempt to change the timezone of dates that are read from the + // database (via Hibernate). We store all dates in the database as UTC. + TimeZone.setDefault(TimeZone.getTimeZone(ZoneOffset.UTC)); //load the properties of the tests testProps = new Properties(); diff --git a/dspace-api/src/test/java/org/dspace/app/bulkedit/DSpaceCSVTest.java b/dspace-api/src/test/java/org/dspace/app/bulkedit/DSpaceCSVIT.java similarity index 65% rename from dspace-api/src/test/java/org/dspace/app/bulkedit/DSpaceCSVTest.java rename to dspace-api/src/test/java/org/dspace/app/bulkedit/DSpaceCSVIT.java index 21a1a67dde2..f4e1e7f2892 100644 --- a/dspace-api/src/test/java/org/dspace/app/bulkedit/DSpaceCSVTest.java +++ b/dspace-api/src/test/java/org/dspace/app/bulkedit/DSpaceCSVIT.java @@ -9,6 +9,9 @@ import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.fail; import java.io.BufferedWriter; @@ -20,7 +23,15 @@ import org.apache.commons.io.FileUtils; import org.apache.logging.log4j.Logger; -import org.dspace.AbstractUnitTest; +import org.dspace.AbstractIntegrationTestWithDatabase; +import org.dspace.builder.CollectionBuilder; +import org.dspace.builder.CommunityBuilder; +import org.dspace.builder.ItemBuilder; +import org.dspace.content.Collection; +import org.dspace.content.Item; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; +import org.junit.Before; import org.junit.Test; @@ -29,11 +40,39 @@ * * @author Stuart Lewis */ -public class DSpaceCSVTest extends AbstractUnitTest { +public class DSpaceCSVIT extends AbstractIntegrationTestWithDatabase { /** * log4j category */ - private static final Logger log = org.apache.logging.log4j.LogManager.getLogger(DSpaceCSVTest.class); + private static final Logger log = org.apache.logging.log4j.LogManager.getLogger(DSpaceCSVIT.class); + + ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + + Item testItem; + + + @Override + @Before + public void setUp() throws Exception { + super.setUp(); + context.turnOffAuthorisationSystem(); + + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + configurationService.addPropertyValue("metadata.hide.dc.subject", true); + + Collection parentCollection = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Parent Collection") + .build(); + + testItem = ItemBuilder.createItem(context, parentCollection).withTitle("Test Item") + .withMetadata("dc", "description", "provenance", "provenance") + .withMetadata("dc", "subject", null, "hidden subject") + .build(); + + context.restoreAuthSystemState(); + } /** * Test the reading and parsing of CSV files @@ -147,4 +186,63 @@ public void testDSpaceCSV() { fail("IO Error while creating test CSV file"); } } + + /** + * Test the hidden metadata for csv is respected + * + */ + @Test + public void testHiddenDspaceCSV() throws Exception { + + DSpaceCSV dSpaceCSV = new DSpaceCSV(false); + + dSpaceCSV.addItem(testItem); + + List lines = dSpaceCSV.getCSVLines(); + + assertThat(lines.size(), equalTo(1)); + + DSpaceCSVLine line = lines.get(0); + + List subject = line.get("dc.subject"); + List provenance = line.get("dc.description.provenance"); + List title = line.get("dc.title"); + + assertNull(subject); + assertNull(provenance); + assertNotNull(title); + assertEquals("Test Item", title.get(0)); + + } + + /** + * Test the hidden metadata is still shown when force is applied. + * + */ + @Test + public void testHiddenDspaceForceCSV() throws Exception { + + DSpaceCSV dSpaceCSV = new DSpaceCSV(true); + + dSpaceCSV.addItem(testItem); + + List lines = dSpaceCSV.getCSVLines(); + + assertThat(lines.size(), equalTo(1)); + + DSpaceCSVLine line = lines.get(0); + + List subject = line.get("dc.subject"); + List provenance = line.get("dc.description.provenance"); + List title = line.get("dc.title"); + + assertNotNull(subject); + assertNotNull(provenance); + assertEquals("hidden subject", subject.get(0)); + assertEquals("provenance", provenance.get(0)); + assertNotNull(title); + assertEquals("Test Item", title.get(0)); + + } + } 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 e6f2be8382c..ca3d7c956bd 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 @@ -254,4 +254,35 @@ public void exportMetadataSearchNonExistinFacetsTest() throws Exception { assertNotNull(exception); assertEquals("nonExisting is not a valid search filter", exception.getMessage()); } + + @Test + public void exportMetadataSearchDoubleQuotedArgumentTest() throws Exception { + context.turnOffAuthorisationSystem(); + Item quotedItem1 = ItemBuilder.createItem(context, collection) + .withTitle("The Special Runnable Item") + .withSubject("quoted-subject") + .build(); + Item quotedItem2 = ItemBuilder.createItem(context, collection) + .withTitle("The Special Item") + .withSubject("quoted-subject") + .build(); + context.restoreAuthSystemState(); + + int result = runDSpaceScript( + "metadata-export-search", + "-q", "title:\"Special Runnable\"", + "-n", filename); + + assertEquals(0, result); + + Item[] expectedResult = new Item[] {quotedItem1}; + checkItemsPresentInFile(filename, expectedResult); + + File file = new File(filename); + try (Reader reader = Files.newReader(file, Charset.defaultCharset()); + CSVReader csvReader = new CSVReader(reader)) { + List lines = csvReader.readAll(); + assertEquals("Unexpected extra items in export", 2, lines.size()); + } + } } diff --git a/dspace-api/src/test/java/org/dspace/app/bulkedit/MetadataImportIT.java b/dspace-api/src/test/java/org/dspace/app/bulkedit/MetadataImportIT.java index de1dcc91c9a..9f28834eff3 100644 --- a/dspace-api/src/test/java/org/dspace/app/bulkedit/MetadataImportIT.java +++ b/dspace-api/src/test/java/org/dspace/app/bulkedit/MetadataImportIT.java @@ -10,6 +10,7 @@ import static junit.framework.TestCase.assertEquals; import static junit.framework.TestCase.assertTrue; import static junit.framework.TestCase.fail; +import static org.junit.Assert.assertNotNull; import java.io.BufferedWriter; import java.io.File; @@ -43,6 +44,8 @@ import org.dspace.scripts.configuration.ScriptConfiguration; import org.dspace.scripts.factory.ScriptServiceFactory; import org.dspace.scripts.service.ScriptService; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; import org.junit.Before; import org.junit.Test; @@ -54,6 +57,8 @@ public class MetadataImportIT extends AbstractIntegrationTestWithDatabase { = EPersonServiceFactory.getInstance().getEPersonService(); private final RelationshipService relationshipService = ContentServiceFactory.getInstance().getRelationshipService(); + private final ConfigurationService configurationService + = DSpaceServicesFactory.getInstance().getConfigurationService(); private Collection collection; private Collection publicationCollection; @@ -305,4 +310,71 @@ public void performImportScript(String[] csv, boolean useTemplate) throws Except csvFile.delete(); } } -} + + @Test + public void metadataImportExceedsLimitTest() throws Exception { + configurationService.setProperty("bulkedit.import.max.items", 1); + String[] csv = {"id,collection,dc.title", + "+," + collection.getHandle() + ",\"Title 1\"", + "+," + collection.getHandle() + ",\"Title 2\""}; + File csvFile = File.createTempFile("dspace-test-import", "csv"); + try { + try (BufferedWriter out = new BufferedWriter( + new OutputStreamWriter(new FileOutputStream(csvFile), "UTF-8"))) { + for (String csvLine : csv) { + out.write(csvLine + "\n"); + } + } + String fileLocation = csvFile.getAbsolutePath(); + String[] args = new String[] {"metadata-import", "-f", fileLocation, "-e", eperson.getEmail(), "-s"}; + TestDSpaceRunnableHandler testDSpaceRunnableHandler = new TestDSpaceRunnableHandler(); + ScriptLauncher.handleScript( + args, ScriptLauncher.getConfig(kernelImpl), testDSpaceRunnableHandler, kernelImpl); + + assertNotNull("The handler should contain an exception", + testDSpaceRunnableHandler.getException()); + + assertTrue("The exception cause should be a MetadataImportException", + testDSpaceRunnableHandler.getException().getCause() instanceof MetadataImportException); + + String exceptionMessage = testDSpaceRunnableHandler.getException().getCause().getMessage(); + assertTrue("The error message does not contain the expected text.", + exceptionMessage.contains("exceeds the configured maximum of 1")); + } finally { + csvFile.delete(); + } + } + + @Test + public void metadataImportWithItemCountBelowLimitTest() throws Exception { + configurationService.setProperty("bulkedit.import.max.items", 2); + String[] csv = {"id,collection,dc.title", + "+," + collection.getHandle() + ",\"Title 1\"", + "+," + collection.getHandle() + ",\"Title 2\""}; + performImportScript(csv); + Item importedItem1 = findItemByName("Title 1"); + Item importedItem2 = findItemByName("Title 2"); + assertNotNull("Should have imported Title 1", importedItem1); + assertNotNull("Should have imported Title 2", importedItem2); + } + + @Test + public void metadataImportWithLimitDisabledTest() throws Exception { + configurationService.setProperty("bulkedit.import.max.items", 0); + String[] csv = {"id,collection,dc.title", + "+," + collection.getHandle() + ",\"Title 1\"", + "+," + collection.getHandle() + ",\"Title 2\""}; + performImportScript(csv); + Item importedItem1 = findItemByName("Title 1"); + Item importedItem2 = findItemByName("Title 2"); + assertNotNull("Should have imported Title 1 with limit disabled", importedItem1); + assertNotNull("Should have imported Title 2 with limit disabled", importedItem2); + } + + @Test + public void metadataImportWithEmptyCSVTest() throws Exception { + String[] csv = {"id,collection,dc.title"}; + performImportScript(csv); + assertEquals(0, IteratorUtils.toList(itemService.findAll(context)).size()); + } +} \ No newline at end of file diff --git a/dspace-api/src/test/java/org/dspace/app/mediafilter/JPEGFilterTest.java b/dspace-api/src/test/java/org/dspace/app/mediafilter/JPEGFilterTest.java new file mode 100644 index 00000000000..1181dc7a60f --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/app/mediafilter/JPEGFilterTest.java @@ -0,0 +1,270 @@ +/** + * 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.mediafilter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; + +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; + +import org.dspace.AbstractUnitTest; +import org.dspace.content.Item; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; +import org.junit.Test; +import org.mockito.Mock; + +public class JPEGFilterTest extends AbstractUnitTest { + + @Mock + private ConfigurationService mockConfigurationService; + + @Mock + private DSpaceServicesFactory mockDSpaceServicesFactory; + + @Mock + private InputStream mockInputStream; + + @Mock + private Item mockItem; + + /** + * Tests that the convertRotationToDegrees method returns 0 for an input value + * that doesn't match any of the defined rotation cases. + */ + @Test + public void testConvertRotationToDegrees_UnknownValue_ReturnsZero() { + int result = JPEGFilter.convertRotationToDegrees(5); + assertEquals(0, result); + } + + /** + * Test getNormalizedInstance method with a null input. + * This tests the edge case of passing a null BufferedImage to the method. + * The method should throw a NullPointerException when given a null input. + */ + @Test(expected = NullPointerException.class) + public void testGetNormalizedInstanceWithNullInput() { + JPEGFilter filter = new JPEGFilter(); + filter.getNormalizedInstance(null); + } + + /** + * Test getThumbDim method with a null BufferedImage input. + * This tests the edge case where the input image is null, which should result in an exception. + */ + @Test(expected = NullPointerException.class) + public void testGetThumbDimWithNullBufferedImage() throws Exception { + JPEGFilter filter = new JPEGFilter(); + Item currentItem = null; + BufferedImage buf = null; + boolean verbose = false; + int xmax = 100; + int ymax = 100; + boolean blurring = false; + boolean hqscaling = false; + int brandHeight = 0; + int brandFontPoint = 0; + int rotation = 0; + String brandFont = null; + + filter.getThumbDim( + currentItem, buf, verbose, xmax, ymax, blurring, hqscaling, + brandHeight, brandFontPoint, rotation, brandFont + ); + } + + /** + * Tests that the rotateImage method returns the original image when the rotation angle is 0. + * This is an edge case explicitly handled in the method implementation. + */ + @Test + public void testRotateImageWithZeroAngle() { + BufferedImage originalImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB); + BufferedImage rotatedImage = JPEGFilter.rotateImage(originalImage, 0); + assertSame( + "When rotation angle is 0, the original image should be returned", + originalImage, rotatedImage + ); + } + + /** + * Test case for convertRotationToDegrees method when input is 6. + * Expected to return 90 degrees for the rotation value of 6. + */ + @Test + public void test_convertRotationToDegrees_whenInputIs6_returns90() { + int input = 6; + int expected = 90; + int result = JPEGFilter.convertRotationToDegrees(input); + assertEquals(expected, result); + } + + /** + * Tests that getBlurredInstance method applies a blur effect to the input image. + * It verifies that the returned image is not null, has the same dimensions as the input, + * and is different from the original image (indicating that blurring has occurred). + */ + @Test + public void test_getBlurredInstance_appliesBlurEffect() { + JPEGFilter filter = new JPEGFilter(); + BufferedImage original = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB); + + BufferedImage blurred = filter.getBlurredInstance(original); + + assertNotNull("Blurred image should not be null", blurred); + assertEquals("Width should be the same", original.getWidth(), blurred.getWidth()); + assertEquals("Height should be the same", original.getHeight(), blurred.getHeight()); + assertNotEquals("Blurred image should be different from original", original, blurred); + } + + /** + * Test case for getBundleName method of JPEGFilter class. + * This test verifies that the getBundleName method returns the expected string "THUMBNAIL". + */ + @Test + public void test_getBundleName_returnsExpectedString() { + JPEGFilter filter = new JPEGFilter(); + String result = filter.getBundleName(); + assertEquals("THUMBNAIL", result); + } + + /** + * Tests that the getDescription method returns the expected string "Generated Thumbnail". + * This verifies that the method correctly provides the description for the JPEG filter. + */ + @Test + public void test_getDescription_1() { + JPEGFilter filter = new JPEGFilter(); + String description = filter.getDescription(); + assertEquals("Generated Thumbnail", description); + } + + /** + * Tests that getFilteredName method appends ".jpg" to the input filename. + */ + @Test + public void test_getFilteredName_appendsJpgExtension() { + JPEGFilter filter = new JPEGFilter(); + String oldFilename = "testimage"; + String expectedResult = "testimage.jpg"; + String actualResult = filter.getFilteredName(oldFilename); + assertEquals(expectedResult, actualResult); + } + + /** + * Test case for getFormatString method of JPEGFilter class. + * Verifies that the method returns the expected string "JPEG". + */ + @Test + public void test_getFormatString_returnsJPEG() { + JPEGFilter filter = new JPEGFilter(); + String result = filter.getFormatString(); + assertEquals("JPEG", result); + } + + /** + * Tests the behavior of getImageRotationUsingImageReader when an ImageProcessingException occurs. + * This test verifies that the method handles an ImageProcessingException by logging the error + * and returning 0 degrees rotation. + */ + @Test + public void test_getImageRotationUsingImageReader_imageProcessingException() { + InputStream errorStream = new InputStream() { + @Override + public int read() throws IOException { + throw new IOException("Simulated image processing error"); + } + }; + int result = JPEGFilter.getImageRotationUsingImageReader(errorStream); + assertEquals(0, result); + } + + /** + * Testcase for getImageRotationUsingImageReader when the image doesn't contain orientation metadata. + * This test verifies that the method returns 0 when there's no ExifIFD0Directory + * or when it doesn't contain the TAG_ORIENTATION. + */ + @Test + public void test_getImageRotationUsingImageReader_noOrientationMetadata() throws IOException { + URL resource = this.getClass().getResource("cat.jpg"); + int rotationAngle = -1; + try (InputStream inputStream = new FileInputStream(resource.getFile())) { + // Call the method under test + rotationAngle = JPEGFilter.getImageRotationUsingImageReader(inputStream); + } + assertEquals(0, rotationAngle); + } + + /** + * Tests the getImageRotationUsingImageReader method when the image contains + * valid EXIF orientation metadata. + * + * This test verifies that the method correctly reads the orientation tag + * from the EXIF metadata and returns the appropriate rotation angle in degrees. + */ + @Test + public void test_getImageRotationUsingImageReader_withValidExifOrientation() throws Exception { + // Create a mock InputStream with EXIF metadata containing orientation information + URL resource = this.getClass().getResource("cat-rotated-90.jpg"); + int rotationAngle = -1; + try (InputStream inputStream = new FileInputStream(resource.getFile())) { + // Call the method under test + rotationAngle = JPEGFilter.getImageRotationUsingImageReader(inputStream); + } + + // Assert the expected rotation angle + // Note: The expected value should be adjusted based on the mock data + assertEquals(90, rotationAngle); + } + + /** + * Tests the getScaledInstance method of JPEGFilter class with higher quality scaling. + * This test verifies that the method correctly scales down an image in multiple passes + * when higherQuality is true and the image dimensions are larger than the target dimensions. + */ + @Test + public void test_getScaledInstance() { + JPEGFilter filter = new JPEGFilter(); + BufferedImage originalImage = new BufferedImage(400, 300, BufferedImage.TYPE_INT_RGB); + int targetWidth = 100; + int targetHeight = 75; + Object hint = RenderingHints.VALUE_INTERPOLATION_BILINEAR; + boolean higherQuality = true; + + BufferedImage result = filter.getScaledInstance(originalImage, targetWidth, targetHeight, hint, higherQuality); + + assertNotNull(result); + assertEquals(targetWidth, result.getWidth()); + assertEquals(targetHeight, result.getHeight()); + } + + /** + * Tests the rotateImage method with a non-zero angle. + * This test verifies that the image is rotated correctly when given a non-zero angle. + */ + @Test + public void test_rotateImage_nonZeroAngle() { + BufferedImage originalImage = new BufferedImage(100, 50, BufferedImage.TYPE_INT_RGB); + int angle = 90; + + BufferedImage rotatedImage = JPEGFilter.rotateImage(originalImage, angle); + + assertNotNull(rotatedImage); + assertEquals(50, rotatedImage.getWidth()); + assertEquals(100, rotatedImage.getHeight()); + } + +} diff --git a/dspace-api/src/test/java/org/dspace/authenticate/ShibAuthenticationTest.java b/dspace-api/src/test/java/org/dspace/authenticate/ShibAuthenticationTest.java new file mode 100644 index 00000000000..11005608f32 --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/authenticate/ShibAuthenticationTest.java @@ -0,0 +1,137 @@ +/** + * 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 static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import javax.servlet.http.HttpServletRequest; + +import org.dspace.AbstractUnitTest; +import org.dspace.content.MetadataField; +import org.dspace.content.service.MetadataFieldService; +import org.dspace.core.Context; +import org.dspace.eperson.EPerson; +import org.dspace.eperson.service.EPersonService; +import org.dspace.services.ConfigurationService; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +/** + * Unit tests for ShibAuthentication + */ +public class ShibAuthenticationTest extends AbstractUnitTest { + + private ShibAuthentication shibAuthentication; + private EPersonService ePersonService; + private ConfigurationService configurationService; + + @Before + public void setup() { + shibAuthentication = new ShibAuthentication(); + ePersonService = mock(EPersonService.class); + shibAuthentication.ePersonService = ePersonService; + configurationService = mock(ConfigurationService.class); + shibAuthentication.configurationService = configurationService; + when(configurationService.getProperty("authentication-shibboleth.netid-header")).thenReturn("SHIB-NETID"); + when(configurationService.getProperty("authentication-shibboleth.email-header")).thenReturn("SHIB-MAIL"); + when(configurationService.getArrayProperty("authentication-shibboleth.eperson.metadata")) + .thenReturn(new String[]{"SHIB-telephone => eperson.phone"}); + when(configurationService.getBooleanProperty("authentication-shibboleth.eperson.metadata.autocreate", true)) + .thenReturn(true); + MetadataFieldService metadataFieldService = mock(MetadataFieldService.class); + shibAuthentication.metadataFieldService = metadataFieldService; + + try { + when(metadataFieldService.findByElement(any(Context.class), any(String.class), any(String.class), any())) + .thenReturn(mock(MetadataField.class)); + } catch (Exception e) { + // ignore checked exceptions from mock + } + } + + @Test + public void testPhoneMetadataUpdateOrder() throws Exception { + Context context = mock(Context.class); + HttpServletRequest request = mock(HttpServletRequest.class); + EPerson eperson = mock(EPerson.class); + when(request.getAttribute("SHIB-NETID")).thenReturn("test-user"); + when(request.getAttribute("SHIB-MAIL")).thenReturn("test@example.com"); + String phoneValue = "555-1234"; + when(request.getAttribute("SHIB-telephone")).thenReturn(phoneValue); + shibAuthentication.initialize(context); + assertNotNull("metadataHeaderMap should be initialized", shibAuthentication.metadataHeaderMap); + assertTrue("metadataHeaderMap should contain SHIB-telephone", shibAuthentication.metadataHeaderMap + .containsKey("SHIB-telephone")); + shibAuthentication.updateEPerson(context, request, eperson); + ArgumentCaptor languageCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor valueCaptor = ArgumentCaptor.forClass(String.class); + + verify(ePersonService, times(1)).setMetadataSingleValue( + any(Context.class), + eq(eperson), + eq("eperson"), + eq("phone"), + isNull(), + languageCaptor.capture(), + valueCaptor.capture() + ); + + assertNull("The language argument should be NULL.", languageCaptor.getValue()); + assertEquals("The value argument should be the phone number.", phoneValue, valueCaptor.getValue()); + } + + @Test + public void testInitializeLoadsMultipleMappings() throws Exception { + Context context = mock(Context.class); + when(configurationService.getArrayProperty("authentication-shibboleth.eperson.metadata")) + .thenReturn(new String[]{ + "SHIB-telephone => eperson.phone", + "SHIB-dept => eperson.department" + }); + shibAuthentication.initialize(context); + + assertNotNull("metadataHeaderMap should be initialized", shibAuthentication.metadataHeaderMap); + assertTrue("metadataHeaderMap should contain SHIB-telephone", shibAuthentication.metadataHeaderMap + .containsKey("SHIB-telephone")); + assertTrue("metadataHeaderMap should contain SHIB-dept", shibAuthentication.metadataHeaderMap + .containsKey("SHIB-dept")); + } + + @Test + public void testNoMetadataMappingNoUpdate() throws Exception { + Context context = mock(Context.class); + HttpServletRequest request = mock(HttpServletRequest.class); + EPerson eperson = mock(EPerson.class); + when(configurationService.getArrayProperty("authentication-shibboleth.eperson.metadata")) + .thenReturn(new String[0]); + shibAuthentication.initialize(context); + shibAuthentication.updateEPerson(context, request, eperson); + + verify(ePersonService, times(0)).setMetadataSingleValue( + any(Context.class), + any(EPerson.class), + anyString(), + anyString(), + any(), + any(), + any() + ); + } +} diff --git a/dspace-api/src/test/java/org/dspace/authority/orcid/MockOrcid.java b/dspace-api/src/test/java/org/dspace/authority/orcid/MockOrcid.java index 511df79f1e5..b6be6d1f3ac 100644 --- a/dspace-api/src/test/java/org/dspace/authority/orcid/MockOrcid.java +++ b/dspace-api/src/test/java/org/dspace/authority/orcid/MockOrcid.java @@ -11,6 +11,7 @@ import java.io.InputStream; +import org.dspace.external.OrcidConnectionException; import org.dspace.external.OrcidRestConnector; import org.mockito.ArgumentMatchers; import org.mockito.Mockito; @@ -38,7 +39,7 @@ public void init() { * Call this to set up mocking for any test classes that need it. We don't set it in init() * or other AbstractIntegrationTest implementations will complain of unnecessary Mockito stubbing */ - public void setupNoResultsSearch() { + public void setupNoResultsSearch() throws OrcidConnectionException { when(orcidRestConnector.get(ArgumentMatchers.startsWith("search?"), ArgumentMatchers.any())) .thenAnswer(new Answer() { @Override @@ -51,7 +52,7 @@ public InputStream answer(InvocationOnMock invocation) { * Call this to set up mocking for any test classes that need it. We don't set it in init() * or other AbstractIntegrationTest implementations will complain of unnecessary Mockito stubbing */ - public void setupSingleSearch() { + public void setupSingleSearch() throws OrcidConnectionException { when(orcidRestConnector.get(ArgumentMatchers.startsWith("search?q=Bollini"), ArgumentMatchers.any())) .thenAnswer(new Answer() { @Override @@ -64,7 +65,7 @@ public InputStream answer(InvocationOnMock invocation) { * Call this to set up mocking for any test classes that need it. We don't set it in init() * or other AbstractIntegrationTest implementations will complain of unnecessary Mockito stubbing */ - public void setupSearchWithResults() { + public void setupSearchWithResults() throws OrcidConnectionException { when(orcidRestConnector.get(ArgumentMatchers.endsWith("/person"), ArgumentMatchers.any())) .thenAnswer(new Answer() { @Override diff --git a/dspace-api/src/test/java/org/dspace/builder/ItemBuilder.java b/dspace-api/src/test/java/org/dspace/builder/ItemBuilder.java index 1dc43405912..70bf1ebaa1a 100644 --- a/dspace-api/src/test/java/org/dspace/builder/ItemBuilder.java +++ b/dspace-api/src/test/java/org/dspace/builder/ItemBuilder.java @@ -112,6 +112,14 @@ public ItemBuilder withScopusIdentifier(String scopus) { return addMetadataValue(item, "dc", "identifier", "scopus", scopus); } + public ItemBuilder withISSN(String issn) { + return addMetadataValue(item, "dc", "identifier", "issn", issn); + } + + public ItemBuilder withISBN(String isbn) { + return addMetadataValue(item, "dc", "identifier", "isbn", isbn); + } + public ItemBuilder withRelationFunding(String funding) { return addMetadataValue(item, "dc", "relation", "funding", funding); } diff --git a/dspace-api/src/test/java/org/dspace/checker/ChecksumCheckerIT.java b/dspace-api/src/test/java/org/dspace/checker/ChecksumCheckerIT.java new file mode 100644 index 00000000000..34198ff1ebf --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/checker/ChecksumCheckerIT.java @@ -0,0 +1,193 @@ +/** + * 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.checker; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.sql.SQLException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import org.apache.commons.io.IOUtils; +import org.dspace.AbstractIntegrationTestWithDatabase; +import org.dspace.builder.BitstreamBuilder; +import org.dspace.builder.CollectionBuilder; +import org.dspace.builder.CommunityBuilder; +import org.dspace.builder.ItemBuilder; +import org.dspace.checker.factory.CheckerServiceFactory; +import org.dspace.checker.service.ChecksumHistoryService; +import org.dspace.checker.service.MostRecentChecksumService; +import org.dspace.content.Bitstream; +import org.dspace.content.Collection; +import org.dspace.content.Community; +import org.dspace.content.Item; +import org.dspace.core.Context; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class ChecksumCheckerIT extends AbstractIntegrationTestWithDatabase { + protected List bitstreams; + protected MostRecentChecksumService checksumService = + CheckerServiceFactory.getInstance().getMostRecentChecksumService(); + + @Before + public void setup() throws Exception { + context.turnOffAuthorisationSystem(); + + Community parentCommunity = CommunityBuilder.createCommunity(context).build(); + Collection collection = CollectionBuilder.createCollection(context, parentCommunity) + .build(); + Item item = ItemBuilder.createItem(context, collection).withTitle("Test item") + .build(); + + int numBitstreams = 3; + bitstreams = new ArrayList<>(); + for (int i = 0; i < numBitstreams; i++) { + String content = "Test bitstream " + i; + bitstreams.add( + BitstreamBuilder.createBitstream( + context, item, IOUtils.toInputStream(content, UTF_8) + ).build() + ); + } + + context.restoreAuthSystemState(); + + // Call the "updateMissingBitstreams" method so that the test bitstreams + // already have checksums in the past when CheckerCommand runs. + // Otherwise, the CheckerCommand will simply update the test + // bitstreams without going through the BitstreamDispatcher. + checksumService = CheckerServiceFactory.getInstance().getMostRecentChecksumService(); + checksumService.updateMissingBitstreams(context); + + // The "updateMissingBitstreams" method updates the test bitstreams in + // a random order. To verify that the expected bitstreams were + // processed, reset the timestamps so that the bitstreams are + // checked in a specific order (oldest first). + Instant checksumInstant = Instant.ofEpochMilli(0); + for (Bitstream bitstream: bitstreams) { + MostRecentChecksum mrc = checksumService.findByBitstream(context, bitstream); + mrc.setProcessStartDate(Date.from(checksumInstant)); + mrc.setProcessEndDate(Date.from(checksumInstant)); + checksumInstant = checksumInstant.plusSeconds(10); + } + context.commit(); + } + + @After + public void cleanUp() throws SQLException { + // Need to clean up ChecksumHistory because of a referential integrity + // constraint violation between the most_recent_checksum table and + // bitstream tables + ChecksumHistoryService checksumHistoryService = CheckerServiceFactory.getInstance().getChecksumHistoryService(); + + for (Bitstream bitstream: bitstreams) { + checksumHistoryService.deleteByBitstream(context, bitstream); + } + } + + @Test + public void testChecksumsRecordedWhenProcesingIsInterrupted() throws SQLException { + CheckerCommand checker = new CheckerCommand(context); + + // The start date to use for the checker process + Date checkerStartDate = Date.from(Instant.now()); + + // Verify that all checksums are before the checker start date + for (Bitstream bitstream: bitstreams) { + MostRecentChecksum checksum = checksumService.findByBitstream(context, bitstream); + Date lastChecksumDate = checksum.getProcessStartDate(); + assertTrue("lastChecksumDate (" + lastChecksumDate + ") <= checkerStartDate (" + checkerStartDate + ")", + lastChecksumDate.before(checkerStartDate)); + } + + // Dispatcher that throws an exception when a third bitstream is + // retrieved. + BitstreamDispatcher dispatcher = new ExpectionThrowingDispatcher( + context, checkerStartDate, false, 2); + checker.setDispatcher(dispatcher); + + + // Run the checksum checker + checker.setProcessStartDate(checkerStartDate); + try { + checker.process(); + fail("SQLException should have been thrown"); + } catch (SQLException sqle) { + // Rollback any pending transaction + context.rollback(); + } + + // Verify that the checksums of the first two bitstreams (that were + // processed before the exception) have been successfully recorded in + // the database, while the third bitstream was not updated. + int bitstreamCount = 0; + for (Bitstream bitstream: bitstreams) { + MostRecentChecksum checksum = checksumService.findByBitstream(context, bitstream); + Date lastChecksumDate = checksum.getProcessStartDate(); + + bitstreamCount = bitstreamCount + 1; + if (bitstreamCount <= 2) { + assertTrue("lastChecksumDate (" + lastChecksumDate + ") <= checkerStartDate (" + checkerStartDate + ")", + lastChecksumDate.after(checkerStartDate)); + } else { + assertTrue("lastChecksumDate (" + lastChecksumDate + ") >= checkerStartDate (" + checkerStartDate + ")", + lastChecksumDate.before(checkerStartDate)); + } + } + } + + /** + * Subclass of SimpleDispatcher that only allows a limited number of "next" + * class before throwing a SQLException. + */ + class ExpectionThrowingDispatcher extends SimpleDispatcher { + // The number of "next" calls to allow before throwing a SQLException + protected int maxNextCalls; + + // The number of "next" method calls seen so far. + protected int numNextCalls = 0; + + /** + * Constructor. + * + * @param context Context + * @param startTime timestamp for beginning of checker process + * @param looping indicates whether checker should loop infinitely + * through most_recent_checksum table + * @param maxNextCalls the number of "next" method calls to allow before + * throwing a SQLException. + */ + public ExpectionThrowingDispatcher(Context context, Date startTime, boolean looping, int maxNextCalls) { + super(context, startTime, looping); + this.maxNextCalls = maxNextCalls; + } + + /** + * Selects the next candidate bitstream. + * + * After "maxNextClass" number of calls, this method throws a + * SQLException. + * + * @throws SQLException if database error + */ + @Override + public synchronized Bitstream next() throws SQLException { + numNextCalls = numNextCalls + 1; + if (numNextCalls > maxNextCalls) { + throw new SQLException("Max 'next' method calls exceeded"); + } + return super.next(); + } + } +} diff --git a/dspace-api/src/test/java/org/dspace/content/BitstreamTest.java b/dspace-api/src/test/java/org/dspace/content/BitstreamTest.java index e85a0fc7b78..abf19f32821 100644 --- a/dspace-api/src/test/java/org/dspace/content/BitstreamTest.java +++ b/dspace-api/src/test/java/org/dspace/content/BitstreamTest.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.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -25,6 +26,9 @@ import java.io.FileInputStream; import java.io.IOException; import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Iterator; import java.util.List; import java.util.UUID; @@ -148,6 +152,44 @@ public void testFindAll() throws SQLException { assertTrue("testFindAll 2", added); } + @Test + public void testFindAllBatches() throws Exception { + //Adding some data for processing and cleaning this up at the end + context.turnOffAuthorisationSystem(); + File f = new File(testProps.get("test.bitstream").toString()); + List inserted = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + Bitstream bs = bitstreamService.create(context, new FileInputStream(f)); + inserted.add(bs); + } + context.restoreAuthSystemState(); + + // sorted list of all bitstreams + List all = bitstreamService.findAll(context); + List expected = new ArrayList<>(all); + expected.sort(Comparator.comparing(bs -> bs.getID().toString())); + + int total = bitstreamService.countTotal(context); + int batchSize = 2; + int numberOfBatches = (int) Math.ceil((double) total / batchSize); + + //collect in batches + List collected = new ArrayList<>(); + for (int i = 0; i < numberOfBatches; i++) { + Iterator it = bitstreamService.findAll(context, batchSize, i * batchSize); + it.forEachRemaining(collected::add); + } + + assertEquals("Batched results should match sorted findAll", expected, collected); + + // Cleanup + context.turnOffAuthorisationSystem(); + for (Bitstream b : inserted) { + bitstreamService.delete(context, b); + } + context.restoreAuthSystemState(); + } + /** * Test of create method, of class Bitstream. */ diff --git a/dspace-api/src/test/java/org/dspace/content/CollectionTest.java b/dspace-api/src/test/java/org/dspace/content/CollectionTest.java index 13d037abf82..f2604328106 100644 --- a/dspace-api/src/test/java/org/dspace/content/CollectionTest.java +++ b/dspace-api/src/test/java/org/dspace/content/CollectionTest.java @@ -1159,6 +1159,184 @@ public void testFindAuthorizedOptimized() throws Exception { assertFalse("testFindAuthorizeOptimized D.C", personDCollections.contains(collectionC)); } + /** + * Test of findAuthorizedEpersonAndGroups method, of class Collection. + * We create some collections and a user and groups and subgroups and add the user to one subgroup + * and one collection + * The parent group will be added to the other collection. + */ + @Test + public void testFindAuthorizedByEPerson() throws Exception { + context.turnOffAuthorisationSystem(); + Community com = communityService.create(null, context); + Collection collectionA = collectionService.create(context, com); + Collection collectionB = collectionService.create(context, com); + Collection collectionC = collectionService.create(context, com); + + com.addCollection(collectionA); + com.addCollection(collectionB); + com.addCollection(collectionC); + + Group groupParent = groupService.create(context); + Group groupChild = groupService.create(context); + + groupService.addMember(context, groupParent, groupChild); + + EPerson epersonA = ePersonService.create(context); + + //Add epersonA to the child group + groupService.addMember(context, groupChild, epersonA); + + //personA can submit to collectionA and collectionC + authorizeService.addPolicy(context, collectionA, Constants.ADD, epersonA); + authorizeService.addPolicy(context, collectionB, Constants.ADD, groupParent); + + context.restoreAuthSystemState(); + + context.setCurrentUser(epersonA); + List personACollections = + collectionService.findAuthorized(context, null, List.of(Constants.ADD, Constants.ADMIN)); + assertTrue("testFindAuthorizedByEPerson A", personACollections.size() == 2); + assertTrue("testFindAuthorizedByEPerson A.A", personACollections.contains(collectionA)); + assertTrue("testFindAuthorizedByEPerson A.B", personACollections.contains(collectionB)); + assertFalse("testFindAuthorizedByEPerson A.C", personACollections.contains(collectionC)); + } + + /** + * Test of testFindAuthorizedEPersonCommunityAdmin method, of class Collection. + * This will test what collections care retrieved if a user is a Com Administrator + * eperson A is Top of B (and by the caso of B,C and D) but not of E + * eperson E is Top of E nad of D so it can get THE E and D Collections + * + */ + @Test + public void testFindAuthorizedEPersonCommunityAdmin() throws Exception { + context.turnOffAuthorisationSystem(); + Community comA = communityService.create(null, context); + Community comB = communityService.create(null, context); + Community comC = communityService.create(null, context); + Community comD = communityService.create(null, context); + Community comE = communityService.create(null, context); + + Collection collectionA1 = collectionService.create(context, comA); + Collection collectionC1 = collectionService.create(context, comC); + Collection collectionC2 = collectionService.create(context, comC); + Collection collectionD1 = collectionService.create(context, comD); + Collection collectionE1 = collectionService.create(context, comE); + Collection collectionE2 = collectionService.create(context, comE); + + //Create Com hierarchies + comA.addSubCommunity(comB); + comA.addSubCommunity(comC); + comB.addSubCommunity(comD); + + comA.addCollection(collectionA1); + comC.addCollection(collectionC1); + comC.addCollection(collectionC2); + comD.addCollection(collectionD1); + comE.addCollection(collectionE1); + comE.addCollection(collectionE2); + + + Group groupA = groupService.create(context); + Group groupB = groupService.create(context); + Group groupC = groupService.create(context); + Group groupD = groupService.create(context); + Group groupE = groupService.create(context); + + EPerson epersonA = ePersonService.create(context); + EPerson epersonB = ePersonService.create(context); + + //Add epersonA to the child group + groupService.addMember(context, groupA, epersonA); + //Add epersonB to the child group + groupService.addMember(context, groupE, epersonB); + groupService.addMember(context, groupD, epersonB); + + //personA can submit to collectionA and collectionB + authorizeService.addPolicy(context, comA, Constants.ADMIN, groupA); + authorizeService.addPolicy(context, comD, Constants.ADMIN, groupD); + authorizeService.addPolicy(context, comE, Constants.ADMIN, groupE); + + context.restoreAuthSystemState(); + + //PersonA Can get AllCollection From Top to Bottom com ComA, but not from ComE + context.setCurrentUser(epersonA); + List personACollectionsAdminCommA = + collectionService.findAuthorized(context, null, List.of(Constants.ADD, Constants.ADMIN)); + assertTrue("testFindAuthorizedEPersonCommunityAdmin A", personACollectionsAdminCommA.size() == 4); + assertTrue("testFindAuthorizedEPersonCommunityAdmin A.A", personACollectionsAdminCommA + .containsAll(List.of(collectionA1, collectionD1, collectionC1, collectionC2))); + assertFalse("testFindAuthorizedEPersonCommunityAdmin A.B", personACollectionsAdminCommA + .containsAll(List.of(collectionE1, collectionE2))); + + //PersonB Can get AllCollection From Top to Bottom com ComE, but not from ComA + context.setCurrentUser(epersonB); + List personACollectionsAdminCommE = + collectionService.findAuthorized(context, null, List.of(Constants.ADD, Constants.ADMIN)); + assertTrue("testFindAuthorizedEPersonCommunityAdmin B", personACollectionsAdminCommE.size() == 3); + assertFalse("testFindAuthorizedEPersonCommunityAdmin B.A", personACollectionsAdminCommE + .containsAll(List.of(collectionA1, collectionC1, collectionC2))); + assertTrue("testFindAuthorizedEPersonCommunityAdmin B.B", personACollectionsAdminCommE + .containsAll(List.of(collectionD1, collectionE1, collectionE2))); + } + + /** + * Test of testFindNotAuthorizedEPersonDifferentActions method, of class Collection. + * We create some collections and a user and a group add the user as ADMIN by adding ti + * toa group and that group to a Collection and add the user as submitter to another + * we pass actions that shouldn't return collections if only those actions are passed + * And we test if only on collection is retrieved if we pass the Corresponding action + */ + @Test + public void testFindAuthorizedEPersonDifferentActions() throws Exception { + context.turnOffAuthorisationSystem(); + Community com = communityService.create(null, context); + Collection collectionA = collectionService.create(context, com); + Collection collectionB = collectionService.create(context, com); + + com.addCollection(collectionA); + com.addCollection(collectionB); + + Group group = groupService.create(context); + + EPerson epersonA = ePersonService.create(context); + + //Add epersonA to the child group + groupService.addMember(context, group, epersonA); + + //personA can submit to collectionA and collectionB + authorizeService.addPolicy(context, collectionA, Constants.ADD, epersonA); + authorizeService.addPolicy(context, collectionB, Constants.ADMIN, group); + + context.restoreAuthSystemState(); + + //Person does not Have other permission than ADD - So should not return a Colelction if we pass other + //Actions. In this case WRITE OR DELETE + context.setCurrentUser(epersonA); + List personACollectionsRD = + collectionService.findAuthorized(context, null, List.of(Constants.WRITE, Constants.DELETE)); + assertTrue("testFindAuthorizedEPersonDifferentActions A", personACollectionsRD.isEmpty()); + assertFalse("testFindAuthorizedEPersonDifferentActions A.A", personACollectionsRD.contains(collectionA)); + assertFalse("testFindAuthorizedEPersonDifferentActions A.B", personACollectionsRD.contains(collectionB)); + + //But It Should get Collection B if we pass the ADMIN Action too + List personACollectionsADD = + collectionService.findAuthorized(context, null, + List.of(Constants.WRITE, Constants.DELETE, Constants.ADD)); + assertTrue("testFindAuthorizedEPersonDifferentActions B", personACollectionsADD.size() == 1); + assertTrue("testFindAuthorizedEPersonDifferentActions B.A", personACollectionsADD.contains(collectionA)); + assertFalse("testFindAuthorizedEPersonDifferentActions B.B", personACollectionsADD.contains(collectionB)); + + //But It Should get Collection A if we pass the ADD Action too + List personACollections = + collectionService.findAuthorized(context, null, + List.of(Constants.WRITE, Constants.DELETE, Constants.ADMIN)); + assertTrue("testFindAuthorizedEPersonDifferentActions C", personACollections.size() == 1); + assertFalse("testFindAuthorizedEPersonDifferentActions C.A", personACollections.contains(collectionA)); + assertTrue("testFindAuthorizedEPersonDifferentActions C.B", personACollections.contains(collectionB)); + } + /** * Test of countItems method, of class Collection. */ diff --git a/dspace-api/src/test/java/org/dspace/content/ItemTest.java b/dspace-api/src/test/java/org/dspace/content/ItemTest.java index 41f26448852..0b87c452e39 100644 --- a/dspace-api/src/test/java/org/dspace/content/ItemTest.java +++ b/dspace-api/src/test/java/org/dspace/content/ItemTest.java @@ -1604,6 +1604,27 @@ public void testMoveSameCollection() throws Exception { verify(itemServiceSpy, times(0)).delete(context, it); } + /** + * Test of move with inherit default policies method, of class Item, where both Collections are the same. + */ + @Test + public void testMoveSameCollectionWithInheritDefaultPolicies() throws Exception { + context.turnOffAuthorisationSystem(); + while (it.getCollections().size() > 1) { + it.removeCollection(it.getCollections().get(0)); + } + + Collection collection = it.getCollections().get(0); + it.setOwningCollection(collection); + ItemService itemServiceSpy = spy(itemService); + + itemService.move(context, it, collection, collection, true); + context.restoreAuthSystemState(); + assertThat("testMoveSameCollection 0", it.getOwningCollection(), notNullValue()); + assertThat("testMoveSameCollection 1", it.getOwningCollection(), equalTo(collection)); + verify(itemServiceSpy, times(0)).delete(context, it); + } + /** * Test of hasUploadedFiles method, of class Item. */ diff --git a/dspace-api/src/test/java/org/dspace/content/VersioningTest.java b/dspace-api/src/test/java/org/dspace/content/VersioningTest.java index 48ace5b9092..b7ebfeb1aee 100644 --- a/dspace-api/src/test/java/org/dspace/content/VersioningTest.java +++ b/dspace-api/src/test/java/org/dspace/content/VersioningTest.java @@ -184,4 +184,23 @@ public void testOriginalVersionDelete() throws Exception { assertThat("Test_version_handle_delete", handleService.resolveToObject(context, handle), nullValue()); context.restoreAuthSystemState(); } + + @Test + public void testGetVersionWithNullPointerException() throws Exception { + context.turnOffAuthorisationSystem(); + // Create item without version + Community community = communityService.create(null, context); + Collection col = collectionService.create(context, community); + WorkspaceItem is = workspaceItemService.create(context, col, false); + Item itemWithoutVersion = installItemService.installItem(context, is); + VersionHistory versionHistory = versionHistoryService.findByItem(context, originalItem); + try { + Version result = versionHistoryService.getVersion(context, versionHistory, itemWithoutVersion); + assertThat("getVersion should return null for item without version", result, nullValue()); + } catch (NullPointerException npe) { + fail("NullPointerException should not be thrown. Method should return null: " + npe.getMessage()); + } finally { + 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 255b070e5ea..524c6407b7b 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 @@ -8,6 +8,7 @@ package org.dspace.content.authority; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import java.io.IOException; @@ -86,6 +87,7 @@ public void testGetMatches() throws IOException, ClassNotFoundException { CoreServiceFactory.getInstance().getPluginService().getNamedPlugin(Class.forName(PLUGIN_INTERFACE), "farm"); assertNotNull(instance); Choices result = instance.getMatches(text, start, limit, locale); + assertNotEquals("At least one match expected", 0, result.values.length); assertEquals("north 40", result.values[0].value); } diff --git a/dspace-api/src/test/java/org/dspace/content/logic/LogicalFilterTest.java b/dspace-api/src/test/java/org/dspace/content/logic/LogicalFilterTest.java index 0e086462204..c84665f2985 100644 --- a/dspace-api/src/test/java/org/dspace/content/logic/LogicalFilterTest.java +++ b/dspace-api/src/test/java/org/dspace/content/logic/LogicalFilterTest.java @@ -57,8 +57,10 @@ import org.dspace.content.service.MetadataValueService; import org.dspace.content.service.WorkspaceItemService; import org.dspace.core.Constants; +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.junit.After; import org.junit.Before; @@ -81,6 +83,7 @@ public class LogicalFilterTest extends AbstractUnitTest { private MetadataValueService metadataValueService = ContentServiceFactory.getInstance().getMetadataValueService(); private AuthorizeService authorizeService = AuthorizeServiceFactory.getInstance().getAuthorizeService(); private GroupService groupService = EPersonServiceFactory.getInstance().getGroupService(); + private EPersonService epersonService = EPersonServiceFactory.getInstance().getEPersonService(); // Logger private static final Logger log = org.apache.logging.log4j.LogManager.getLogger(LogicalFilterTest.class); @@ -603,6 +606,10 @@ public void testReadableByGroupCondition() { groupService.setName(g, "Test Group"); groupService.update(context, g); authorizeService.addPolicy(context, itemOne, Constants.READ, g); + EPerson e = epersonService.create(context); + epersonService.update(context, e); + authorizeService.removeAllPolicies(context, itemThree); + authorizeService.addPolicy(context, itemThree, Constants.READ, e); context.restoreAuthSystemState(); } catch (AuthorizeException | SQLException e) { fail("Exception thrown adding group READ policy to item: " + itemOne + ": " + e.getMessage()); @@ -620,6 +627,9 @@ public void testReadableByGroupCondition() { // Test the filter on itemTwo - this item has no policies: expect false assertFalse("itemTwo unexpectedly matched the 'is readable by Test Group' test", filter.getResult(context, itemTwo)); + // Test the filter on itemThree - this item has only a person related policy: expect false + assertFalse("itemThree unexpectedly matched the 'is readable by Test Group' test", + filter.getResult(context, itemThree)); } catch (LogicalStatementException e) { log.error(e.getMessage()); fail("LogicalStatementException thrown testing the ReadableByGroup filter" + e.getMessage()); 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 d31acd85451..bc4e4359dfd 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 @@ -669,8 +669,8 @@ public void testDeleteItemWithMultipleVersions() throws Exception { @Test public void testFindItemsWithEditNoRights() throws Exception { context.setCurrentUser(eperson); - List result = itemService.findItemsWithEdit(context, 0, 10); - int count = itemService.countItemsWithEdit(context); + List result = itemService.findItemsWithEdit(context, "", 0, 10); + int count = itemService.countItemsWithEdit(context, ""); assertThat(result.size(), equalTo(0)); assertThat(count, equalTo(0)); } @@ -682,8 +682,8 @@ public void testFindAndCountItemsWithEditEPerson() throws Exception { .withAction(Constants.WRITE) .build(); context.setCurrentUser(eperson); - List result = itemService.findItemsWithEdit(context, 0, 10); - int count = itemService.countItemsWithEdit(context); + List result = itemService.findItemsWithEdit(context, "", 0, 10); + int count = itemService.countItemsWithEdit(context, ""); assertThat(result.size(), equalTo(1)); assertThat(count, equalTo(1)); } @@ -695,8 +695,8 @@ public void testFindAndCountItemsWithAdminEPerson() throws Exception { .withAction(Constants.ADMIN) .build(); context.setCurrentUser(eperson); - List result = itemService.findItemsWithEdit(context, 0, 10); - int count = itemService.countItemsWithEdit(context); + List result = itemService.findItemsWithEdit(context, "", 0, 10); + int count = itemService.countItemsWithEdit(context, ""); assertThat(result.size(), equalTo(1)); assertThat(count, equalTo(1)); } @@ -714,8 +714,8 @@ public void testFindAndCountItemsWithEditGroup() throws Exception { .withAction(Constants.WRITE) .build(); context.setCurrentUser(eperson); - List result = itemService.findItemsWithEdit(context, 0, 10); - int count = itemService.countItemsWithEdit(context); + List result = itemService.findItemsWithEdit(context, "", 0, 10); + int count = itemService.countItemsWithEdit(context, ""); assertThat(result.size(), equalTo(1)); assertThat(count, equalTo(1)); } @@ -733,8 +733,8 @@ public void testFindAndCountItemsWithAdminGroup() throws Exception { .withAction(Constants.ADMIN) .build(); context.setCurrentUser(eperson); - List result = itemService.findItemsWithEdit(context, 0, 10); - int count = itemService.countItemsWithEdit(context); + List result = itemService.findItemsWithEdit(context, "", 0, 10); + int count = itemService.countItemsWithEdit(context, ""); assertThat(result.size(), equalTo(1)); assertThat(count, equalTo(1)); } diff --git a/dspace-api/src/test/java/org/dspace/content/service/ItemServiceTest.java b/dspace-api/src/test/java/org/dspace/content/service/ItemServiceTest.java index b5bde3e1db9..b29186e9e34 100644 --- a/dspace-api/src/test/java/org/dspace/content/service/ItemServiceTest.java +++ b/dspace-api/src/test/java/org/dspace/content/service/ItemServiceTest.java @@ -487,8 +487,8 @@ public void testDeleteItemWithMultipleVersions() throws Exception { @Test public void testFindItemsWithEditNoRights() throws Exception { context.setCurrentUser(eperson); - List result = itemService.findItemsWithEdit(context, 0, 10); - int count = itemService.countItemsWithEdit(context); + List result = itemService.findItemsWithEdit(context, "", 0, 10); + int count = itemService.countItemsWithEdit(context, ""); assertThat(result.size(), equalTo(0)); assertThat(count, equalTo(0)); } @@ -500,8 +500,8 @@ public void testFindAndCountItemsWithEditEPerson() throws Exception { .withAction(Constants.WRITE) .build(); context.setCurrentUser(eperson); - List result = itemService.findItemsWithEdit(context, 0, 10); - int count = itemService.countItemsWithEdit(context); + List result = itemService.findItemsWithEdit(context, "", 0, 10); + int count = itemService.countItemsWithEdit(context, ""); assertThat(result.size(), equalTo(1)); assertThat(count, equalTo(1)); } @@ -513,8 +513,8 @@ public void testFindAndCountItemsWithAdminEPerson() throws Exception { .withAction(Constants.ADMIN) .build(); context.setCurrentUser(eperson); - List result = itemService.findItemsWithEdit(context, 0, 10); - int count = itemService.countItemsWithEdit(context); + List result = itemService.findItemsWithEdit(context, "", 0, 10); + int count = itemService.countItemsWithEdit(context, ""); assertThat(result.size(), equalTo(1)); assertThat(count, equalTo(1)); } @@ -532,8 +532,8 @@ public void testFindAndCountItemsWithEditGroup() throws Exception { .withAction(Constants.WRITE) .build(); context.setCurrentUser(eperson); - List result = itemService.findItemsWithEdit(context, 0, 10); - int count = itemService.countItemsWithEdit(context); + List result = itemService.findItemsWithEdit(context, "", 0, 10); + int count = itemService.countItemsWithEdit(context, ""); assertThat(result.size(), equalTo(1)); assertThat(count, equalTo(1)); } @@ -551,8 +551,8 @@ public void testFindAndCountItemsWithAdminGroup() throws Exception { .withAction(Constants.ADMIN) .build(); context.setCurrentUser(eperson); - List result = itemService.findItemsWithEdit(context, 0, 10); - int count = itemService.countItemsWithEdit(context); + List result = itemService.findItemsWithEdit(context, "", 0, 10); + int count = itemService.countItemsWithEdit(context, ""); assertThat(result.size(), equalTo(1)); assertThat(count, equalTo(1)); } 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 3b50258a5a2..5013f2cb669 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 @@ -8,8 +8,12 @@ package org.dspace.ctask.general; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; import org.dspace.builder.CollectionBuilder; import org.dspace.builder.CommunityBuilder; @@ -19,6 +23,7 @@ import org.dspace.core.factory.CoreServiceFactory; import org.dspace.curate.Curator; import org.dspace.identifier.AbstractIdentifierProviderIT; +import org.dspace.identifier.IdentifierProvider; import org.dspace.identifier.VersionedHandleIdentifierProvider; import org.dspace.identifier.VersionedHandleIdentifierProviderWithCanonicalHandles; import org.dspace.services.ConfigurationService; @@ -45,6 +50,7 @@ public void testPerform() // Must remove any cached named plugins before creating a new one CoreServiceFactory.getInstance().getPluginService().clearNamedPluginClasses(); // Define a new task dynamically + String[] prevTaskDef = configurationService.getArrayProperty(P_TASK_DEF); configurationService.setProperty(P_TASK_DEF, CreateMissingIdentifiers.class.getCanonicalName() + " = " + TASK_NAME); @@ -82,5 +88,64 @@ public void testPerform() curator.curate(context, item); int status = curator.getStatus(TASK_NAME); assertEquals("Curation should succeed", Curator.CURATE_SUCCESS, status); + configurationService.setProperty(P_TASK_DEF, prevTaskDef); + } + + @Test + public void testCreationOfMissingHandles() throws IOException { + // Must remove any cached named plugins before creating a new one + CoreServiceFactory.getInstance().getPluginService().clearNamedPluginClasses(); + // Define a new task dynamically + String[] prevTaskDef = configurationService.getArrayProperty(P_TASK_DEF); + configurationService.setProperty(P_TASK_DEF, + CreateMissingIdentifiers.class.getCanonicalName() + " = " + TASK_NAME); + + // deactivate all identifier provider + List identifierProviders = identifierService.getProviders(); + List identifierProviderClasses = + identifierProviders.stream().map(Object::getClass).distinct().collect(Collectors.toList()); + for (Class identifierProviderClass : identifierProviderClasses) { + unregisterProvider(identifierProviderClass); + } + + try { + context.setCurrentUser(admin); + parentCommunity = CommunityBuilder.createCommunity(context) + .build(); + Collection collection = CollectionBuilder.createCollection(context, parentCommunity) + .build(); + // create item and assert it did not got any handle + Item item = ItemBuilder.createItem(context, collection) + .build(); + assertNull("Internal error in createMissingIdentifiersIT: item should not have a handle", + item.getHandle()); + + // setup the curator + Curator curator = new Curator(); + curator.addTask(TASK_NAME); + + // register the default Handle Provider + registerProvider(VersionedHandleIdentifierProvider.class); + + /* + * Now, verify curate with default Handle Provider works + * (and that our re-registration of the default provider above was successful) + * Use the uuid as reference to the item as the curation system takes handles and uuid as cli arguments. + * Do not use the item reference, to be sure the curator and the curation task are able to work without + * handle. + */ + curator.curate(context, item.getID().toString()); + int status = curator.getStatus(TASK_NAME); + assertEquals("Curation should succeed", Curator.CURATE_SUCCESS, status); + // assure we got a handle + assertNotNull("Curation task CreateMissingIdentifiers, did not assign a handle.", item.getHandle()); + } finally { + // restore the identifierProviders for following tests + for (Class identifierProviderClass : identifierProviderClasses) { + registerProvider(identifierProviderClass); + } + // restore curation task configuration + configurationService.setProperty(P_TASK_DEF, prevTaskDef); + } } } diff --git a/dspace-api/src/test/java/org/dspace/orcid/service/OrcidEntityFactoryServiceIT.java b/dspace-api/src/test/java/org/dspace/orcid/service/OrcidEntityFactoryServiceIT.java index 17bc6ee531c..33f5cc3102e 100644 --- a/dspace-api/src/test/java/org/dspace/orcid/service/OrcidEntityFactoryServiceIT.java +++ b/dspace-api/src/test/java/org/dspace/orcid/service/OrcidEntityFactoryServiceIT.java @@ -73,6 +73,9 @@ public class OrcidEntityFactoryServiceIT extends AbstractIntegrationTestWithData private Collection projects; + private static final String isbn = "978-0-439-02348-1"; + private static final String issn = "1234-1234X"; + @Before public void setup() { @@ -117,6 +120,7 @@ public void testWorkCreation() { .withLanguage("en_US") .withType("Book") .withIsPartOf("Journal") + .withISBN(isbn) .withDoiIdentifier("doi-id") .withScopusIdentifier("scopus-id") .build(); @@ -149,11 +153,100 @@ public void testWorkCreation() { assertThat(work.getExternalIdentifiers(), notNullValue()); List externalIds = work.getExternalIdentifiers().getExternalIdentifier(); - assertThat(externalIds, hasSize(3)); + assertThat(externalIds, hasSize(4)); + assertThat(externalIds, has(selfExternalId("doi", "doi-id"))); + assertThat(externalIds, has(selfExternalId("eid", "scopus-id"))); + assertThat(externalIds, has(selfExternalId("handle", publication.getHandle()))); + // Book type should have SELF rel for ISBN + assertThat(externalIds, has(selfExternalId("isbn", isbn))); + + } + + @Test + public void testJournalArticleAndISSN() { + context.turnOffAuthorisationSystem(); + + Item publication = ItemBuilder.createItem(context, publications) + .withTitle("Test publication") + .withAuthor("Walter White") + .withAuthor("Jesse Pinkman") + .withEditor("Editor") + .withIssueDate("2021-04-30") + .withDescriptionAbstract("Publication description") + .withLanguage("en_US") + .withType("Article") + .withIsPartOf("Journal") + .withISSN(issn) + .withDoiIdentifier("doi-id") + .withScopusIdentifier("scopus-id") + .build(); + + context.restoreAuthSystemState(); + + Activity activity = entityFactoryService.createOrcidObject(context, publication); + assertThat(activity, instanceOf(Work.class)); + + Work work = (Work) activity; + assertThat(work.getJournalTitle(), notNullValue()); + assertThat(work.getJournalTitle().getContent(), is("Journal")); + assertThat(work.getLanguageCode(), is("en")); + assertThat(work.getPublicationDate(), matches(date("2021", "04", "30"))); + assertThat(work.getShortDescription(), is("Publication description")); + assertThat(work.getPutCode(), nullValue()); + assertThat(work.getWorkType(), is(WorkType.JOURNAL_ARTICLE)); + assertThat(work.getWorkTitle(), notNullValue()); + assertThat(work.getWorkTitle().getTitle(), notNullValue()); + assertThat(work.getWorkTitle().getTitle().getContent(), is("Test publication")); + assertThat(work.getWorkContributors(), notNullValue()); + assertThat(work.getUrl(), matches(urlEndsWith(publication.getHandle()))); + + List contributors = work.getWorkContributors().getContributor(); + assertThat(contributors, hasSize(3)); + assertThat(contributors, has(contributor("Walter White", AUTHOR, FIRST))); + assertThat(contributors, has(contributor("Editor", EDITOR, FIRST))); + assertThat(contributors, has(contributor("Jesse Pinkman", AUTHOR, ADDITIONAL))); + + assertThat(work.getExternalIdentifiers(), notNullValue()); + + List externalIds = work.getExternalIdentifiers().getExternalIdentifier(); + assertThat(externalIds, hasSize(4)); assertThat(externalIds, has(selfExternalId("doi", "doi-id"))); assertThat(externalIds, has(selfExternalId("eid", "scopus-id"))); assertThat(externalIds, has(selfExternalId("handle", publication.getHandle()))); + // journal-article should have PART_OF rel for ISSN + assertThat(externalIds, has(externalId("issn", issn, Relationship.PART_OF))); + } + @Test + public void testJournalWithISSN() { + context.turnOffAuthorisationSystem(); + + Item publication = ItemBuilder.createItem(context, publications) + .withTitle("Test journal") + .withEditor("Editor") + .withType("Journal") + .withISSN(issn) + .build(); + + context.restoreAuthSystemState(); + + Activity activity = entityFactoryService.createOrcidObject(context, publication); + assertThat(activity, instanceOf(Work.class)); + + Work work = (Work) activity; + assertThat(work.getWorkType(), is(WorkType.JOURNAL_ISSUE)); + assertThat(work.getWorkTitle(), notNullValue()); + assertThat(work.getWorkTitle().getTitle(), notNullValue()); + assertThat(work.getWorkTitle().getTitle().getContent(), is("Test journal")); + assertThat(work.getUrl(), matches(urlEndsWith(publication.getHandle()))); + + assertThat(work.getExternalIdentifiers(), notNullValue()); + + List externalIds = work.getExternalIdentifiers().getExternalIdentifier(); + assertThat(externalIds, hasSize(2)); + // journal-issue should have SELF rel for ISSN + assertThat(externalIds, has(selfExternalId("issn", issn))); + assertThat(externalIds, has(selfExternalId("handle", publication.getHandle()))); } @Test @@ -163,6 +256,7 @@ public void testEmptyWorkWithUnknownTypeCreation() { Item publication = ItemBuilder.createItem(context, publications) .withType("TYPE") + .withISSN(issn) .build(); context.restoreAuthSystemState(); @@ -183,8 +277,9 @@ public void testEmptyWorkWithUnknownTypeCreation() { assertThat(work.getExternalIdentifiers(), notNullValue()); List externalIds = work.getExternalIdentifiers().getExternalIdentifier(); - assertThat(externalIds, hasSize(1)); + assertThat(externalIds, hasSize(2)); assertThat(externalIds, has(selfExternalId("handle", publication.getHandle()))); + assertThat(externalIds, has(externalId("issn", issn, Relationship.PART_OF))); } @Test diff --git a/dspace-api/src/test/java/org/dspace/statistics/export/ITIrusExportUsageEventListener.java b/dspace-api/src/test/java/org/dspace/statistics/export/ITIrusExportUsageEventListener.java index e28e8284a21..77a51a92f35 100644 --- a/dspace-api/src/test/java/org/dspace/statistics/export/ITIrusExportUsageEventListener.java +++ b/dspace-api/src/test/java/org/dspace/statistics/export/ITIrusExportUsageEventListener.java @@ -116,6 +116,7 @@ public void setUp() throws Exception { configurationService.setProperty("irus.statistics.tracker.enabled", true); configurationService.setProperty("irus.statistics.tracker.type-field", "dc.type"); configurationService.setProperty("irus.statistics.tracker.type-value", "Excluded type"); + configurationService.setProperty("oai.identifier.prefix", "localhost"); context.turnOffAuthorisationSystem(); diff --git a/dspace-api/src/test/java/org/dspace/statistics/export/processor/ExportEventProcessorIT.java b/dspace-api/src/test/java/org/dspace/statistics/export/processor/ExportEventProcessorIT.java index e42003e4fc8..1ae7d2e4905 100644 --- a/dspace-api/src/test/java/org/dspace/statistics/export/processor/ExportEventProcessorIT.java +++ b/dspace-api/src/test/java/org/dspace/statistics/export/processor/ExportEventProcessorIT.java @@ -62,6 +62,7 @@ public void setUp() throws Exception { configurationService.setProperty("irus.statistics.tracker.enabled", true); configurationService.setProperty("irus.statistics.tracker.type-field", "dc.type"); configurationService.setProperty("irus.statistics.tracker.type-value", "Excluded type"); + configurationService.setProperty("oai.identifier.prefix", "localhost"); context.turnOffAuthorisationSystem(); publication = EntityTypeBuilder.createEntityTypeBuilder(context, "Publication").build(); diff --git a/dspace-api/src/test/java/org/dspace/storage/bitstore/BitstreamStorageServiceImplIT.java b/dspace-api/src/test/java/org/dspace/storage/bitstore/BitstreamStorageServiceImplIT.java new file mode 100644 index 00000000000..5b15cba1c4c --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/storage/bitstore/BitstreamStorageServiceImplIT.java @@ -0,0 +1,262 @@ +/** + * 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.storage.bitstore; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.fail; + +import java.io.IOException; +import java.io.InputStream; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.Map; + +import org.apache.commons.io.IOUtils; +import org.dspace.AbstractIntegrationTestWithDatabase; +import org.dspace.authorize.AuthorizeException; +import org.dspace.builder.BitstreamBuilder; +import org.dspace.builder.CollectionBuilder; +import org.dspace.builder.CommunityBuilder; +import org.dspace.builder.ItemBuilder; +import org.dspace.content.Bitstream; +import org.dspace.content.Collection; +import org.dspace.content.Item; +import org.dspace.content.factory.ContentServiceFactory; +import org.dspace.content.service.BitstreamService; +import org.dspace.core.Context; +import org.dspace.storage.bitstore.factory.StorageServiceFactory; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class BitstreamStorageServiceImplIT extends AbstractIntegrationTestWithDatabase { + private BitstreamService bitstreamService = ContentServiceFactory.getInstance().getBitstreamService(); + private BitstreamStorageServiceImpl bitstreamStorageService = + (BitstreamStorageServiceImpl) StorageServiceFactory.getInstance().getBitstreamStorageService(); + private Collection collection; + + private Map originalBitstores; + + private static final Integer SOURCE_STORE = 0; + private static final Integer DEST_STORE = 1; + + @Rule + public final TemporaryFolder tempStoreDir = new TemporaryFolder(); + + @Before + public void setup() throws Exception { + + context.turnOffAuthorisationSystem(); + + parentCommunity = CommunityBuilder.createCommunity(context) + .build(); + + collection = CollectionBuilder.createCollection(context, parentCommunity) + .build(); + + originalBitstores = bitstreamStorageService.getStores(); + Map stores = new HashMap<>(); + DSBitStoreService sourceStore = new DSBitStoreService(); + sourceStore.setBaseDir(tempStoreDir.newFolder("src")); + + stores.put(SOURCE_STORE, sourceStore); + bitstreamStorageService.setStores(stores); + + context.restoreAuthSystemState(); + } + + @After + public void cleanUp() throws IOException { + // Restore the bitstore storage stores + bitstreamStorageService.setStores(originalBitstores); + } + + /** + * Test batch commit checkpointing, using the default batch commit size of 1 + * + * @throws Exception if an exception occurs. + */ + @Test + public void testDefaultBatchCommitSize() throws Exception { + Context context = this.context; + + // Destination assetstore fails after two bitstreams have been migrated + DSBitStoreService destinationStore = new LimitedTempDSBitStoreService(tempStoreDir, 2); + Map stores = bitstreamStorageService.getStores(); + stores.put(DEST_STORE, destinationStore); + + // Create three bitstreams in the source assetstore + createBitstreams(context, 3); + + // Three bitstreams in source assetstore at the start + assertThat(bitstreamService.countByStoreNumber(context, SOURCE_STORE).intValue(), equalTo(3)); + + // No bitstreams in destination assetstore at the start + assertThat(bitstreamService.countByStoreNumber(context, DEST_STORE).intValue(), equalTo(0)); + + /// Commit any pending transaction to database + context.commit(); + + // Migrate bitstreams + context.turnOffAuthorisationSystem(); + + boolean deleteOld = false; + Integer batchCommitSize = 1; + try { + bitstreamStorageService.migrate( + context, SOURCE_STORE, DEST_STORE, deleteOld, + batchCommitSize + ); + fail("IOException should have been thrown"); + } catch (IOException ioe) { + // Rollback any pending transaction + context.rollback(); + } + + context.restoreAuthSystemState(); + + // One bitstream should still be in the source assetstore, due to the + // interrupted migration + assertThat(bitstreamService.countByStoreNumber(context, SOURCE_STORE).intValue(), equalTo(1)); + + // Two bitstreams should have migrated to the destination assetstore + assertThat(bitstreamService.countByStoreNumber(context, DEST_STORE).intValue(), equalTo(2)); + } + + /** + * Test batch commit checkpointing, using the default batch commit size of 3 + * + * @throws Exception if an exception occurs. + */ + @Test + public void testBatchCommitSizeThree() throws Exception { + Context context = this.context; + + // Destination assetstore fails after four bitstreams have been migrated + DSBitStoreService destinationStore = new LimitedTempDSBitStoreService(tempStoreDir, 4); + Map stores = bitstreamStorageService.getStores(); + stores.put(DEST_STORE, destinationStore); + + // Create five bitstreams in the source assetstore + createBitstreams(context, 5); + + // Five bitstreams in source assetstore at the start + assertThat(bitstreamService.countByStoreNumber(context, SOURCE_STORE).intValue(), equalTo(5)); + + // No bitstreams in destination assetstore at the start + assertThat(bitstreamService.countByStoreNumber(context, DEST_STORE).intValue(), equalTo(0)); + + // Commit any pending transaction to database + context.commit(); + + // Migrate bitstreams + context.turnOffAuthorisationSystem(); + + boolean deleteOld = false; + Integer batchCommitSize = 3; + try { + bitstreamStorageService.migrate( + context, SOURCE_STORE, DEST_STORE, deleteOld, + batchCommitSize + ); + fail("IOException should have been thrown"); + } catch (IOException ioe) { + // Rollback any pending transaction + context.rollback(); + } + + context.restoreAuthSystemState(); + + // Since the batch commit size is 3, only three bitstreams should be + // marked as migrated, so there should still be two bitstreams + // in the source assetstore, due to the interrupted migration + assertThat(bitstreamService.countByStoreNumber(context, SOURCE_STORE).intValue(), equalTo(2)); + + // Three bitstreams should have migrated to the destination assetstore + assertThat(bitstreamService.countByStoreNumber(context, DEST_STORE).intValue(), equalTo(3)); + } + + private void createBitstreams(Context context, int numBitstreams) + throws SQLException { + context.turnOffAuthorisationSystem(); + for (int i = 0; i < numBitstreams; i++) { + String content = "Test bitstream " + i; + createBitstream(content); + } + context.restoreAuthSystemState(); + context.commit(); + } + + private Bitstream createBitstream(String content) { + try { + return BitstreamBuilder + .createBitstream(context, createItem(), toInputStream(content)) + .build(); + } catch (SQLException | AuthorizeException | IOException e) { + throw new RuntimeException(e); + } + } + + private Item createItem() { + return ItemBuilder.createItem(context, collection) + .withTitle("Test item") + .build(); + } + + + private InputStream toInputStream(String content) { + return IOUtils.toInputStream(content, UTF_8); + } + + + /** + * DSBitStoreService variation that only allows a limited number of puts + * to the bit store before throwing an IOException, to test the + * error handling of the BitstreamStorageService.migrate() method. + */ + class LimitedTempDSBitStoreService extends DSBitStoreService { + // The number of put calls allowed before throwing an IOException + protected int maxPuts = Integer.MAX_VALUE; + + // The number of "put" method class seen so far. + protected int putCallCount = 0; + + /** + * Constructor. + * + * @param maxPuts the number of put calls to allow before throwing an + * IOException + */ + public LimitedTempDSBitStoreService(TemporaryFolder tempStoreDir, int maxPuts) throws IOException { + super(); + setBaseDir(tempStoreDir.newFolder()); + this.maxPuts = maxPuts; + } + + /** + * Store a stream of bits. + * + * After "maxPut" number of calls, this method throws an IOException. + * @param in The stream of bits to store + * @throws java.io.IOException If a problem occurs while storing the bits + */ + @Override + public void put(Bitstream bitstream, InputStream in) throws IOException { + putCallCount = putCallCount + 1; + if (putCallCount > maxPuts) { + throw new IOException("Max 'put' method calls exceeded"); + } else { + super.put(bitstream, in); + } + } + } +} diff --git a/dspace-api/src/test/java/org/dspace/storage/bitstore/ClarinS3BitStoreServiceIT.java b/dspace-api/src/test/java/org/dspace/storage/bitstore/ClarinS3BitStoreServiceIT.java new file mode 100644 index 00000000000..da93ba05ef3 --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/storage/bitstore/ClarinS3BitStoreServiceIT.java @@ -0,0 +1,326 @@ +/** + * 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.storage.bitstore; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.apache.commons.io.IOUtils.toInputStream; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertArrayEquals; +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.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.sql.SQLException; + +import org.apache.commons.io.FileUtils; +import org.dspace.AbstractIntegrationTestWithDatabase; +import org.dspace.authorize.AuthorizeException; +import org.dspace.builder.BitstreamBuilder; +import org.dspace.builder.CollectionBuilder; +import org.dspace.builder.CommunityBuilder; +import org.dspace.builder.ItemBuilder; +import org.dspace.content.Bitstream; +import org.dspace.content.Collection; +import org.dspace.content.Item; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.testcontainers.localstack.LocalStackContainer; +import org.testcontainers.utility.DockerImageName; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.ChecksumAlgorithm; + +/** + * Covers the CLARIN-only parts of the S3 asset store, which upstream's {@link S3BitStoreServiceIT} + * does not touch: {@link S3BitStoreService#getFile(Bitstream)} and {@link SyncS3BitStoreService}. + * + * @author Milan Majchrak (dspace at dataquest.sk) + */ +public class ClarinS3BitStoreServiceIT extends AbstractIntegrationTestWithDatabase { + + // Pinned to the same image upstream's S3BitStoreServiceIT uses. + private static DockerImageName localstackName = DockerImageName.parse("localstack/localstack:4.14.0"); + + @SuppressWarnings("resource") + private static LocalStackContainer localstackContainer = new LocalStackContainer(localstackName).withServices("s3"); + + private static S3AsyncClient s3AsyncClient; + + private static final String BUCKET_NAME = "clarin-testbucket"; + + private Collection collection; + + private File assetstoreDir; + + private DSBitStoreService dsBitStoreService; + + private ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + + @BeforeClass + public static void setupS3() { + localstackContainer.start(); + + s3AsyncClient = S3AsyncClient.crtBuilder() + .endpointOverride(localstackContainer.getEndpoint()) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(localstackContainer.getAccessKey(), + localstackContainer.getSecretKey()) + )) + .region(Region.of(localstackContainer.getRegion())) + .build(); + } + + @AfterClass + public static void cleanupS3() { + localstackContainer.close(); + s3AsyncClient.close(); + } + + @Before + public void setup() throws Exception { + configurationService.setProperty("assetstore.s3.enabled", "true"); + // clarin-dspace.cfg turns both of these on; the tests opt in explicitly instead + configurationService.setProperty("sync.storage.service.enabled", "false"); + configurationService.setProperty("s3.upload.by.parts.enabled", "false"); + + assetstoreDir = Files.createTempDirectory("clarin-assetstore").toFile(); + dsBitStoreService = new DSBitStoreService(); + dsBitStoreService.setBaseDir(assetstoreDir); + dsBitStoreService.init(); + + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context).build(); + collection = CollectionBuilder.createCollection(context, parentCommunity).build(); + context.restoreAuthSystemState(); + } + + /** + * {@link S3BitStoreService#getFile(Bitstream)} is declared by the fork's BitStoreService and has no + * upstream counterpart, so nothing else exercises it. + */ + @Test + public void getFileReturnsStoredContent() throws Exception { + String content = "CLARIN getFile content"; + S3BitStoreService store = initStore(new S3BitStoreService(s3AsyncClient)); + + Bitstream bitstream = createBitstream(content); + store.put(bitstream, toInputStream(content, UTF_8)); + + File file = store.getFile(bitstream); + + assertTrue(file.exists()); + assertThat(FileUtils.readFileToString(file, UTF_8), is(content)); + assertThat(file.length(), is((long) content.length())); + } + + /** + * Default path: a single putObject, the CRT client splits large files on its own. + */ + @Test + public void syncStoreUploadsFluently() throws Exception { + String content = "CLARIN fluent upload"; + SyncS3BitStoreService store = initSyncStore(false, false); + + Bitstream bitstream = createBitstream(content); + store.put(bitstream, toInputStream(content, UTF_8)); + + assertThat(readS3(store, bitstream), is(content)); + assertThat(bitstream.getSizeBytes(), is((long) content.length())); + // the local assetstore must stay untouched while syncEnabled is false + assertFalse(dsBitStoreService.getFile(bitstream).exists()); + } + + /** + * `s3.upload.by.parts.enabled` path: explicit createMultipartUpload / uploadPart / + * completeMultipartUpload, with the per-part ETag compared against a locally computed MD5. + * That comparison is what regressed in the v1 -> v2 port, because v2 returns the ETag quoted. + */ + @Test + public void syncStoreUploadsByParts() throws Exception { + String content = "CLARIN multipart upload"; + SyncS3BitStoreService store = initSyncStore(false, true); + + Bitstream bitstream = createBitstream(content); + store.put(bitstream, toInputStream(content, UTF_8)); + + assertThat(readS3(store, bitstream), is(content)); + assertThat(bitstream.getSizeBytes(), is((long) content.length())); + } + + /** + * The same path across a real part boundary. With the 50 MB production part size and a short fixture + * the loop only ever ran once, so the offset arithmetic, the ranged request body and the ordering of + * CompletedParts were never executed - a review found the loop untested even though it had just been + * rewritten. S3 requires every part but the last to be at least 5 MB, so the part size is lowered + * rather than the fixture made enormous. + */ + @Test + public void syncStoreUploadsInMultipleParts() throws Exception { + int partSize = 5 * 1024 * 1024; + byte[] content = new byte[partSize + 4096]; + for (int i = 0; i < content.length; i++) { + content[i] = (byte) (i % 251); + } + + SyncS3BitStoreService store = initSyncStore(false, true); + store.setUploadPartSizeBytes(partSize); + + Bitstream bitstream = createBitstream(content); + store.put(bitstream, new ByteArrayInputStream(content)); + + assertThat(bitstream.getSizeBytes(), is((long) content.length)); + // read the object back byte for byte - a wrong offset would corrupt the second part silently + assertArrayEquals(content, FileUtils.readFileToByteArray(store.getFile(bitstream))); + } + + /** + * The endpoint override and path-style flag are the fork's headline S3 delta, and every other test + * injects a ready-made client - so `amazonClientBuilderBy` never ran and a tripwire planted in it + * left all 21 S3 tests green. This builds a client through that method and uses it for real. + */ + @Test + public void clientBuilderAppliesEndpointAndPathStyle() { + S3AsyncClient client = S3BitStoreService.amazonClientBuilderBy( + Region.of(localstackContainer.getRegion()), + StaticCredentialsProvider.create(AwsBasicCredentials.create( + localstackContainer.getAccessKey(), localstackContainer.getSecretKey())), + localstackContainer.getEndpoint().toString(), + 10.0, + 8 * 1024 * 1024L, + null, + true).get(); + + try { + // Only reachable if endpointOverride was applied - the default endpoint is real AWS. + client.createBucket(r -> r.bucket("clarin-builder-probe")).join(); + assertTrue(new S3BitStoreService(client).doesBucketExist("clarin-builder-probe")); + } finally { + client.close(); + } + } + + /** + * The reason SyncS3BitStoreService exists: every asset is also written to the local assetstore. + */ + @Test + public void syncStoreWritesLocalCopy() throws Exception { + String content = "CLARIN synced to local assetstore"; + SyncS3BitStoreService store = initSyncStore(true, false); + + Bitstream bitstream = createBitstream(content); + store.put(bitstream, toInputStream(content, UTF_8)); + + assertThat(readS3(store, bitstream), is(content)); + + File localFile = dsBitStoreService.getFile(bitstream); + assertTrue("asset was not mirrored into the local assetstore", localFile.exists()); + assertThat(FileUtils.readFileToString(localFile, UTF_8), is(content)); + } + + /** + * remove() has to clear both stores when syncing is on. + */ + @Test + public void syncStoreRemovesFromBothStores() throws Exception { + String content = "CLARIN removed from both"; + SyncS3BitStoreService store = initSyncStore(true, false); + + Bitstream bitstream = createBitstream(content); + store.put(bitstream, toInputStream(content, UTF_8)); + assertTrue(dsBitStoreService.getFile(bitstream).exists()); + + store.remove(bitstream); + + assertFalse(dsBitStoreService.getFile(bitstream).exists()); + assertFalse(objectExists(store, bitstream)); + } + + /** + * bitstore.xml is the only place the store is configured in production and `s3Store` is lazy-init, + * so nothing otherwise forces Spring to bind its properties. Two of them are easy to get wrong: + * `maxConcurrency` is a blank config value bound to an Integer, and `s3ChecksumAlgorithm` is a + * String bound to an SDK enum. + */ + @Test + public void springWiringBindsStoreProperties() { + SyncS3BitStoreService store = DSpaceServicesFactory.getInstance().getServiceManager() + .getServiceByName("s3Store", SyncS3BitStoreService.class); + + assertNotNull("s3Store bean could not be created from bitstore.xml", store); + assertThat(store.getS3ChecksumAlgorithm(), is(ChecksumAlgorithm.CRC32)); + assertThat(store.getTargetThroughputGbps(), is(10.0)); + assertThat(store.getMinPartSizeBytes(), is(8 * 1024 * 1024L)); + assertNull("blank assetstore.s3.maxConcurrency has to bind to null", store.getMaxConcurrency()); + assertFalse(store.getPathStyleAccessEnabled()); + } + + private T initStore(T store) throws IOException { + ReflectionTestUtils.setField(store, "s3AsyncClient", s3AsyncClient); + store.setEnabled(true); + store.setBucketName(BUCKET_NAME); + store.init(); + return store; + } + + private SyncS3BitStoreService initSyncStore(boolean syncEnabled, boolean uploadByParts) throws IOException { + SyncS3BitStoreService store = new SyncS3BitStoreService(syncEnabled, uploadByParts); + ReflectionTestUtils.setField(store, "configurationService", configurationService); + ReflectionTestUtils.setField(store, "dsBitStoreService", dsBitStoreService); + return initStore(store); + } + + private String readS3(S3BitStoreService store, Bitstream bitstream) throws IOException { + return FileUtils.readFileToString(store.getFile(bitstream), UTF_8); + } + + private boolean objectExists(S3BitStoreService store, Bitstream bitstream) { + try { + String key = store.getFullKey(bitstream.getInternalId()); + s3AsyncClient.headObject(r -> r.bucket(BUCKET_NAME).key(key)).join(); + return true; + } catch (Exception e) { + return false; + } + } + + private Bitstream createBitstream(String content) { + return createBitstream(toInputStream(content, UTF_8)); + } + + private Bitstream createBitstream(byte[] content) { + return createBitstream(new ByteArrayInputStream(content)); + } + + private Bitstream createBitstream(InputStream content) { + context.turnOffAuthorisationSystem(); + try { + Item item = ItemBuilder.createItem(context, collection).build(); + return BitstreamBuilder + .createBitstream(context, item, content) + .build(); + } catch (SQLException | AuthorizeException | IOException e) { + throw new RuntimeException(e); + } finally { + context.restoreAuthSystemState(); + } + } +} diff --git a/dspace-api/src/test/java/org/dspace/storage/bitstore/S3BitStoreServiceIT.java b/dspace-api/src/test/java/org/dspace/storage/bitstore/S3BitStoreServiceIT.java index 63d88e95011..4478684abdb 100644 --- a/dspace-api/src/test/java/org/dspace/storage/bitstore/S3BitStoreServiceIT.java +++ b/dspace-api/src/test/java/org/dspace/storage/bitstore/S3BitStoreServiceIT.java @@ -7,13 +7,12 @@ */ package org.dspace.storage.bitstore; -import static com.amazonaws.regions.Regions.DEFAULT_REGION; import static java.nio.charset.StandardCharsets.UTF_8; import static org.dspace.storage.bitstore.S3BitStoreService.CSA; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasEntry; +import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.notNullValue; @@ -24,8 +23,10 @@ import static org.junit.Assert.assertTrue; import java.io.File; +import java.io.FileWriter; import java.io.IOException; import java.io.InputStream; +import java.io.PrintWriter; import java.nio.file.Paths; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -34,15 +35,6 @@ import java.util.List; import java.util.Map; -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.AnonymousAWSCredentials; -import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.AmazonS3ClientBuilder; -import com.amazonaws.services.s3.model.AmazonS3Exception; -import com.amazonaws.services.s3.model.Bucket; -import com.amazonaws.services.s3.model.ObjectMetadata; -import io.findify.s3mock.S3Mock; import org.apache.commons.io.IOUtils; import org.apache.commons.lang.BooleanUtils; import org.dspace.AbstractIntegrationTestWithDatabase; @@ -60,44 +52,71 @@ import org.dspace.services.factory.DSpaceServicesFactory; import org.hamcrest.Matcher; import org.hamcrest.Matchers; -import org.junit.After; +import org.junit.AfterClass; import org.junit.Before; +import org.junit.BeforeClass; import org.junit.Test; - - +import org.testcontainers.localstack.LocalStackContainer; +import org.testcontainers.utility.DockerImageName; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.Bucket; +import software.amazon.awssdk.services.s3.model.ChecksumAlgorithm; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; /** * @author Luca Giamminonni (luca.giamminonni at 4science.com) */ public class S3BitStoreServiceIT extends AbstractIntegrationTestWithDatabase { + // Pin to version 4.1.4 of Docker image. Newer versions (starting with 2026-03-0) require an Auth Token. + // See https://blog.localstack.cloud/localstack-for-aws-release-2026-03-0 + private static DockerImageName localstackName = DockerImageName.parse("localstack/localstack:4.14.0"); - private static final String DEFAULT_BUCKET_NAME = "dspace-asset-localhost"; + @SuppressWarnings("resource") + private static LocalStackContainer localstackContainer = new LocalStackContainer(localstackName).withServices("s3"); - private S3BitStoreService s3BitStoreService; + private static S3AsyncClient s3AsyncClient; - private AmazonS3 amazonS3Client; + private static final String DEFAULT_BUCKET_NAME = "dspace-asset-localhost"; - private S3Mock s3Mock; + private S3BitStoreService s3BitStoreService; private Collection collection; private ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + @BeforeClass + public static void setupS3() { + localstackContainer.start(); + + s3AsyncClient = S3AsyncClient.crtBuilder() + .endpointOverride(localstackContainer.getEndpoint()) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(localstackContainer.getAccessKey(), + localstackContainer.getSecretKey()) + )) + .region(Region.of(localstackContainer.getRegion())) + .build(); + } + + @AfterClass + public static void cleanupS3() { + localstackContainer.close(); + s3AsyncClient.close(); + } @Before public void setup() throws Exception { - configurationService.setProperty("assetstore.s3.enabled", "true"); - s3Mock = new S3Mock.Builder().withPort(8001).withInMemoryBackend().build(); - s3Mock.start(); - - amazonS3Client = createAmazonS3Client(); - - s3BitStoreService = new S3BitStoreService(amazonS3Client); + s3BitStoreService = new S3BitStoreService(s3AsyncClient); s3BitStoreService.setEnabled(BooleanUtils.toBoolean( configurationService.getProperty("assetstore.s3.enabled"))); - s3BitStoreService.setBufferSize(22); + s3BitStoreService.setS3ChecksumAlgorithm(ChecksumAlgorithm.SHA256); + context.turnOffAuthorisationSystem(); parentCommunity = CommunityBuilder.createCommunity(context) @@ -109,22 +128,17 @@ public void setup() throws Exception { context.restoreAuthSystemState(); } - @After - public void cleanUp() { - s3Mock.shutdown(); - } - @Test public void testBitstreamPutAndGetWithAlreadyPresentBucket() throws IOException { String bucketName = "testbucket"; - amazonS3Client.createBucket(bucketName); + s3AsyncClient.createBucket(r -> r.bucket(bucketName)).join(); s3BitStoreService.setBucketName(bucketName); s3BitStoreService.init(); - assertThat(amazonS3Client.listBuckets(), contains(bucketNamed(bucketName))); + assertThat(s3AsyncClient.listBuckets().join().buckets(), hasItem(bucketNamed(bucketName))); context.turnOffAuthorisationSystem(); String content = "Test bitstream content"; @@ -146,7 +160,7 @@ public void testBitstreamPutAndGetWithAlreadyPresentBucket() throws IOException private void checkGetPut(String bucketName, String content, Bitstream bitstream) throws IOException { s3BitStoreService.put(bitstream, toInputStream(content)); - String expectedChecksum = Utils.toHex(generateChecksum(content)); + String expectedChecksum = Utils.toHex(generateChecksum("MD5", content)); assertThat(bitstream.getSizeBytes(), is((long) content.length())); assertThat(bitstream.getChecksum(), is(expectedChecksum)); @@ -154,20 +168,16 @@ private void checkGetPut(String bucketName, String content, Bitstream bitstream) InputStream inputStream = s3BitStoreService.get(bitstream); assertThat(IOUtils.toString(inputStream, UTF_8), is(content)); - - String key = s3BitStoreService.getFullKey(bitstream.getInternalId()); - ObjectMetadata objectMetadata = amazonS3Client.getObjectMetadata(bucketName, key); - assertThat(objectMetadata.getContentMD5(), is(expectedChecksum)); } @Test - public void testBitstreamPutAndGetWithoutSpecifingBucket() throws IOException { + public void testBitstreamPutAndGetWithoutSpecifyingBucket() throws IOException { s3BitStoreService.init(); assertThat(s3BitStoreService.getBucketName(), is(DEFAULT_BUCKET_NAME)); - assertThat(amazonS3Client.listBuckets(), contains(bucketNamed(DEFAULT_BUCKET_NAME))); + assertThat(s3AsyncClient.listBuckets().join().buckets(), hasItem(bucketNamed(DEFAULT_BUCKET_NAME))); context.turnOffAuthorisationSystem(); String content = "Test bitstream content"; @@ -176,7 +186,7 @@ public void testBitstreamPutAndGetWithoutSpecifingBucket() throws IOException { s3BitStoreService.put(bitstream, toInputStream(content)); - String expectedChecksum = Utils.toHex(generateChecksum(content)); + String expectedChecksum = Utils.toHex(generateChecksum("MD5", content)); assertThat(bitstream.getSizeBytes(), is((long) content.length())); assertThat(bitstream.getChecksum(), is(expectedChecksum)); @@ -184,11 +194,6 @@ public void testBitstreamPutAndGetWithoutSpecifingBucket() throws IOException { InputStream inputStream = s3BitStoreService.get(bitstream); assertThat(IOUtils.toString(inputStream, UTF_8), is(content)); - - String key = s3BitStoreService.getFullKey(bitstream.getInternalId()); - ObjectMetadata objectMetadata = amazonS3Client.getObjectMetadata(DEFAULT_BUCKET_NAME, key); - assertThat(objectMetadata.getContentMD5(), is(expectedChecksum)); - } @Test @@ -210,9 +215,9 @@ public void testBitstreamPutAndGetWithSubFolder() throws IOException { String key = s3BitStoreService.getFullKey(bitstream.getInternalId()); assertThat(key, startsWith("test/DSpace7/")); - ObjectMetadata objectMetadata = amazonS3Client.getObjectMetadata(DEFAULT_BUCKET_NAME, key); - assertThat(objectMetadata, notNullValue()); - + HeadObjectResponse response = s3AsyncClient.headObject(r -> + r.bucket(DEFAULT_BUCKET_NAME).key(key)).join(); + assertThat(response, notNullValue()); } @Test @@ -232,8 +237,8 @@ public void testBitstreamDeletion() throws IOException { s3BitStoreService.remove(bitstream); IOException exception = assertThrows(IOException.class, () -> s3BitStoreService.get(bitstream)); - assertThat(exception.getCause(), instanceOf(AmazonS3Exception.class)); - assertThat(((AmazonS3Exception) exception.getCause()).getStatusCode(), is(404)); + assertThat(exception.getCause(), instanceOf(AwsServiceException.class)); + assertThat(((AwsServiceException) exception.getCause()).statusCode(), is(404)); } @@ -253,6 +258,14 @@ public void testAbout() throws IOException { assertThat(about.size(), is(0)); about = s3BitStoreService.about(bitstream, List.of("size_bytes")); + + { + PrintWriter out = new PrintWriter(new FileWriter("/tmp/about.txt")); + out.println("moo"); + out.println(about); + out.close(); + } + assertThat(about, hasEntry("size_bytes", 22L)); assertThat(about.size(), is(1)); @@ -261,7 +274,7 @@ public void testAbout() throws IOException { assertThat(about, hasEntry(is("modified"), notNullValue())); assertThat(about.size(), is(2)); - String expectedChecksum = Utils.toHex(generateChecksum(content)); + String expectedChecksum = Utils.toHex(generateChecksum("MD5", content)); about = s3BitStoreService.about(bitstream, List.of("size_bytes", "modified", "checksum")); assertThat(about, hasEntry("size_bytes", 22L)); @@ -275,7 +288,6 @@ public void testAbout() throws IOException { assertThat(about, hasEntry("checksum", expectedChecksum)); assertThat(about, hasEntry("checksum_algorithm", CSA)); assertThat(about.size(), is(4)); - } @Test @@ -406,16 +418,16 @@ public void givenBitStreamIdentifierWithSlashesWhenSanitizedThenSlashesMustBeRem public void testDoNotInitializeConfigured() throws Exception { String assetstores3enabledOldValue = configurationService.getProperty("assetstore.s3.enabled"); configurationService.setProperty("assetstore.s3.enabled", "false"); - s3BitStoreService = new S3BitStoreService(amazonS3Client); + s3BitStoreService = new S3BitStoreService(s3AsyncClient); s3BitStoreService.init(); assertFalse(s3BitStoreService.isInitialized()); assertFalse(s3BitStoreService.isEnabled()); configurationService.setProperty("assetstore.s3.enabled", assetstores3enabledOldValue); } - private byte[] generateChecksum(String content) { + private byte[] generateChecksum(String algorithm, String content) { try { - MessageDigest m = MessageDigest.getInstance("MD5"); + MessageDigest m = MessageDigest.getInstance(algorithm); m.update(content.getBytes()); return m.digest(); } catch (NoSuchAlgorithmException e) { @@ -423,13 +435,6 @@ private byte[] generateChecksum(String content) { } } - private AmazonS3 createAmazonS3Client() { - return AmazonS3ClientBuilder.standard() - .withCredentials(new AWSStaticCredentialsProvider(new AnonymousAWSCredentials())) - .withEndpointConfiguration(new EndpointConfiguration("http://127.0.0.1:8001", DEFAULT_REGION.getName())) - .build(); - } - private Item createItem() { return ItemBuilder.createItem(context, collection) .withTitle("Test item") @@ -447,7 +452,7 @@ private Bitstream createBitstream(String content) { } private Matcher bucketNamed(String name) { - return LambdaMatcher.matches(bucket -> bucket.getName().equals(name)); + return LambdaMatcher.matches(bucket -> bucket.name().equals(name)); } private InputStream toInputStream(String content) { diff --git a/dspace-api/src/test/java/org/dspace/storage/bitstore/S3DirectDownloadServiceTest.java b/dspace-api/src/test/java/org/dspace/storage/bitstore/S3DirectDownloadServiceTest.java index 906cdb04a1b..568c140cc21 100644 --- a/dspace-api/src/test/java/org/dspace/storage/bitstore/S3DirectDownloadServiceTest.java +++ b/dspace-api/src/test/java/org/dspace/storage/bitstore/S3DirectDownloadServiceTest.java @@ -11,17 +11,17 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.UnsupportedEncodingException; import java.net.URL; -import java.util.Date; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; import org.dspace.AbstractUnitTest; import org.dspace.services.ConfigurationService; import org.dspace.storage.bitstore.service.S3DirectDownloadService; @@ -31,6 +31,13 @@ import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.springframework.test.util.ReflectionTestUtils; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; /** * Test class for S3DirectDownloadService. @@ -46,129 +53,178 @@ public class S3DirectDownloadServiceTest extends AbstractUnitTest { @Mock private ConfigurationService configService; @Mock - private AmazonS3 amazonS3; + private S3AsyncClient s3AsyncClient; + @Mock + private S3Presigner s3Presigner; @Before + @SuppressWarnings("unchecked") public void setUp() throws Exception { MockitoAnnotations.openMocks(this); - when(amazonS3.doesObjectExist(anyString(), anyString())).thenReturn(true); + + // Resolve the consumer for real, so a null bucket/key behaves as "object not found" like the v1 + // `doesObjectExist(bucket, key)` call this replaced. + when(s3AsyncClient.headObject(any(Consumer.class))).thenAnswer(invocation -> { + Consumer consumer = invocation.getArgument(0); + HeadObjectRequest.Builder builder = HeadObjectRequest.builder(); + consumer.accept(builder); + HeadObjectRequest request = builder.build(); + if (request.bucket() == null || request.key() == null) { + return CompletableFuture.failedFuture(NoSuchKeyException.builder().build()); + } + return CompletableFuture.completedFuture(HeadObjectResponse.builder().build()); + }); s3DirectDownloadService = new S3DirectDownloadServiceImpl(); ReflectionTestUtils.setField(s3DirectDownloadService, "s3BitStoreService", s3BitstoreService); ReflectionTestUtils.setField(s3DirectDownloadService, "configurationService", configService); - // Reflectively set the mock’s private/public field - ReflectionTestUtils.setField(s3BitstoreService, "s3Service", amazonS3); + // Reflectively set the mock's protected field + ReflectionTestUtils.setField(s3BitstoreService, "s3AsyncClient", s3AsyncClient); + // Pre-seed the presigner so init() does not try to build a real one from empty credentials + ReflectionTestUtils.setField(s3DirectDownloadService, "s3Presigner", s3Presigner); ReflectionTestUtils.invokeMethod(s3DirectDownloadService, "init"); } + /** Stub the presigner so that presignGetObject returns a request pointing at the given URL. */ + private void presignReturns(String url) throws Exception { + PresignedGetObjectRequest presigned = mock(PresignedGetObjectRequest.class); + when(presigned.url()).thenReturn(new URL(url)); + when(s3Presigner.presignGetObject(any(GetObjectPresignRequest.class))).thenReturn(presigned); + } + @Test public void generatePresignedUrl() throws Exception { // Mock the presigned URL generation - URL fakeUrl = new URL("https://example.com/foo"); - when(amazonS3.generatePresignedUrl(any(GeneratePresignedUrlRequest.class))) - .thenReturn(fakeUrl); + presignReturns("https://example.com/foo"); - // Rum the method to generate the presigned URL + // Run the method to generate the presigned URL String url = s3DirectDownloadService.generatePresignedUrl("bucket", "key", 120, "myfile.txt"); // Compare the generated URL with the mocked one assertEquals("https://example.com/foo", url); // Verify that the presigned URL was generated with the correct parameters - GeneratePresignedUrlRequest req = captureRequest(); - assertEquals("bucket", req.getBucketName()); - assertEquals("key", req.getKey()); - assertTrue(req.getRequestParameters() - .get("response-content-disposition") + GetObjectPresignRequest req = captureRequest(); + assertEquals("bucket", req.getObjectRequest().bucket()); + assertEquals("key", req.getObjectRequest().key()); + assertTrue(req.getObjectRequest().responseContentDisposition() .contains("attachment; filename=\"myfile.txt\"")); - assertTrue(req.getExpiration().after(new Date())); + assertEquals(Duration.ofSeconds(120), req.signatureDuration()); } - // Zero expiration → URL still generated with expiration == now (or slightly after) + // Zero expiration → URL still generated, with a zero-length signature window @Test public void zeroExpiration() throws Exception { - URL fake = new URL("https://zero"); - when(amazonS3.generatePresignedUrl(any(GeneratePresignedUrlRequest.class))).thenReturn(fake); + presignReturns("https://zero"); s3DirectDownloadService.generatePresignedUrl("b", "k", 0, "f"); - GeneratePresignedUrlRequest req = captureRequest(); - // Expiration should be >= now - Date expiration = req.getExpiration(); - assertTrue("Expiration should not be in the past by more than 1 second", - expiration.getTime() >= new Date().getTime() - 1000); + assertEquals(Duration.ZERO, captureRequest().signatureDuration()); } - // Negative expiration → expiration in the past + // Negative expiration → signature window already elapsed @Test public void negativeExpiration() throws Exception { - URL fake = new URL("https://neg"); - when(amazonS3.generatePresignedUrl(any(GeneratePresignedUrlRequest.class))).thenReturn(fake); + presignReturns("https://neg"); s3DirectDownloadService.generatePresignedUrl("b", "k", -30, "f"); - GeneratePresignedUrlRequest req = captureRequest(); - // Expiration < now + a small slack (1s) - assertTrue(req.getExpiration().before(new Date(System.currentTimeMillis() + 1000))); + assertTrue(captureRequest().signatureDuration().isNegative()); } // DesiredFilename with control chars / path traversal @Test public void weirdFilename() throws Exception { - URL fake = new URL("https://weird"); - when(amazonS3.generatePresignedUrl(any(GeneratePresignedUrlRequest.class))).thenReturn(fake); + presignReturns("https://weird"); String weird = "../secret\nname\t.txt"; s3DirectDownloadService.generatePresignedUrl("b", "k", 60, weird); - GeneratePresignedUrlRequest req = captureRequest(); + GetObjectPresignRequest req = captureRequest(); - String cd = req.getRequestParameters().get("response-content-disposition"); + String cd = req.getObjectRequest().responseContentDisposition(); // Should start with attachment and include both filename and filename* assertTrue(cd.startsWith("attachment; filename=\"")); assertTrue(cd.contains("filename=")); assertTrue(cd.contains("filename*=")); - // Make sure the filename is sanitized, sanitized are only `\\r\\n\"` + // The ASCII fallback now drops every control character, not just CR/LF - the old sanitiser let a + // TAB through and, worse, did not escape a backslash, so a name ending in `\` terminated the + // quoted string early and swallowed the filename* parameter. String fallbackName = cd.split("filename=\"")[1].split("\"")[0]; - assertTrue(fallbackName.contains("../")); - assertTrue(fallbackName.contains("\t")); - assertFalse(fallbackName.contains("\n")); - assertFalse(fallbackName.contains("\"")); + assertTrue(fallbackName, fallbackName.contains("../")); + assertFalse(fallbackName, fallbackName.contains("\t")); + assertFalse(fallbackName, fallbackName.contains("\n")); + assertFalse(fallbackName, fallbackName.contains("\r")); // It's valid and desirable to include UTF-8 assertTrue(cd.contains("UTF-8")); } - // Underlying AmazonS3 throws → IllegalArgumentException + // A trailing backslash used to escape the closing quote and swallow filename* + @Test + public void backslashInFilename() throws Exception { + presignReturns("https://backslash"); + + s3DirectDownloadService.generatePresignedUrl("b", "k", 60, "evil\\"); + String cd = captureRequest().getObjectRequest().responseContentDisposition(); + + assertTrue(cd, cd.contains("filename=\"evil\\\\\"")); + assertTrue(cd, cd.contains("filename*=UTF-8''evil%5C")); + } + + // The caller's disposition has to win, otherwise enabling direct downloads kills inline preview + @Test + public void callerSuppliedDispositionIsUsedVerbatim() throws Exception { + presignReturns("https://inline"); + + String supplied = "inline; filename=\"paper.pdf\"; filename*=UTF-8''paper.pdf"; + s3DirectDownloadService.generatePresignedUrl("b", "k", 60, "paper.pdf", supplied); + + assertEquals(supplied, captureRequest().getObjectRequest().responseContentDisposition()); + } + + // ... and a blank override still falls back to attachment + @Test + public void blankDispositionFallsBackToAttachment() throws Exception { + presignReturns("https://fallback"); + + s3DirectDownloadService.generatePresignedUrl("b", "k", 60, "paper.pdf", null); + + assertTrue(captureRequest().getObjectRequest().responseContentDisposition() + .startsWith("attachment; ")); + } + + // Null filename → IllegalArgumentException @Test(expected = IllegalArgumentException.class) public void nullFilename() throws Exception { s3DirectDownloadService.generatePresignedUrl("b", "k", 60, null); } - // Underlying AmazonS3 throws → bubbles up + // Underlying presigner throws → bubbles up @Test(expected = RuntimeException.class) - public void amazonThrows() throws UnsupportedEncodingException { - when(amazonS3.generatePresignedUrl(any())).thenThrow(new RuntimeException("boom")); + public void presignerThrows() throws UnsupportedEncodingException { + when(s3Presigner.presignGetObject(any(GetObjectPresignRequest.class))) + .thenThrow(new RuntimeException("boom")); s3DirectDownloadService.generatePresignedUrl("b", "k", 1, "f"); } - // Bucket key == null → should IllegalArgumentException + // Bucket == null → should IllegalArgumentException @Test(expected = IllegalArgumentException.class) public void nullBucket() throws UnsupportedEncodingException { s3DirectDownloadService.generatePresignedUrl(null, "k", 60, "f"); } - // Bucket key == null → should NPE + // Key == null → should IllegalArgumentException @Test(expected = IllegalArgumentException.class) public void nullKey() throws UnsupportedEncodingException { s3DirectDownloadService.generatePresignedUrl("b", null, 60, "f"); } // helper to pull out the single captured request - private GeneratePresignedUrlRequest captureRequest() { - ArgumentCaptor cap = - ArgumentCaptor.forClass(GeneratePresignedUrlRequest.class); - verify(amazonS3, atLeastOnce()).generatePresignedUrl(cap.capture()); + private GetObjectPresignRequest captureRequest() { + ArgumentCaptor cap = + ArgumentCaptor.forClass(GetObjectPresignRequest.class); + verify(s3Presigner, atLeastOnce()).presignGetObject(cap.capture()); return cap.getValue(); } -} \ No newline at end of file +} diff --git a/dspace-api/src/test/java/org/dspace/util/ContentDispositionUtilsTest.java b/dspace-api/src/test/java/org/dspace/util/ContentDispositionUtilsTest.java new file mode 100644 index 00000000000..4fc53a58a5f --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/util/ContentDispositionUtilsTest.java @@ -0,0 +1,93 @@ +/** + * 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.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** + * The two defects this helper was extracted to fix are the first two tests: a backslash used to escape + * the closing quote of the ASCII fallback, and a space used to arrive as `+`. + * + * @author Milan Majchrak (dspace at dataquest.sk) + */ +public class ContentDispositionUtilsTest { + + @Test + public void backslashMustNotTerminateTheQuotedString() { + String header = ContentDispositionUtils.build(ContentDispositionUtils.ATTACHMENT, "evil\\"); + + // the fallback has to carry an escaped backslash, not a bare one that eats the closing quote + assertTrue(header, header.contains("filename=\"evil\\\\\"")); + // and filename* must still be present and parseable + assertTrue(header, header.contains("filename*=UTF-8''evil%5C")); + } + + @Test + public void spaceMustBePercentEncodedNotPlus() { + String header = ContentDispositionUtils.build(ContentDispositionUtils.ATTACHMENT, "my report.pdf"); + + assertTrue(header, header.contains("filename*=UTF-8''my%20report.pdf")); + assertFalse(header, header.contains("+")); + } + + @Test + public void literalPlusSurvivesAsPercent2B() { + String header = ContentDispositionUtils.build(ContentDispositionUtils.ATTACHMENT, "a+b.txt"); + + assertTrue(header, header.contains("filename*=UTF-8''a%2Bb.txt")); + } + + @Test + public void quoteIsEscapedInTheFallback() { + String header = ContentDispositionUtils.build(ContentDispositionUtils.ATTACHMENT, "say \"hi\".txt"); + + assertTrue(header, header.contains("filename=\"say \\\"hi\\\".txt\"")); + } + + @Test + public void crlfCannotInjectAHeader() { + String header = ContentDispositionUtils.build(ContentDispositionUtils.ATTACHMENT, "a\r\nX-Evil: 1.txt"); + + assertFalse(header, header.contains("\r")); + assertFalse(header, header.contains("\n")); + } + + @Test + public void nonAsciiFallsBackToUnderscoresButKeepsUtf8Name() { + String header = ContentDispositionUtils.build(ContentDispositionUtils.ATTACHMENT, "žluťoučký.txt"); + + // only the four non-ASCII letters are replaced, one underscore each + assertTrue(header, header.contains("filename=\"_lu_ou_k_.txt\"")); + assertTrue(header, header.contains("filename*=UTF-8''%C5%BElu%C5%A5ou%C4%8Dk%C3%BD.txt")); + } + + @Test + public void dispositionIsHonoured() { + assertTrue(ContentDispositionUtils.build(ContentDispositionUtils.INLINE, "a.pdf") + .startsWith("inline; ")); + assertTrue(ContentDispositionUtils.build(ContentDispositionUtils.ATTACHMENT, "a.pdf") + .startsWith("attachment; ")); + } + + @Test + public void plainNameRoundTrips() { + assertEquals("attachment; filename=\"corpus.zip\"; filename*=UTF-8''corpus.zip", + ContentDispositionUtils.build(ContentDispositionUtils.ATTACHMENT, "corpus.zip")); + } + + @Test + public void nullNameIsRejected() { + assertThrows(IllegalArgumentException.class, + () -> ContentDispositionUtils.build(ContentDispositionUtils.ATTACHMENT, null)); + } +} diff --git a/dspace-api/src/test/resources/org/dspace/app/mediafilter/cat-rotated-90.jpg b/dspace-api/src/test/resources/org/dspace/app/mediafilter/cat-rotated-90.jpg new file mode 100644 index 00000000000..5c0f91c4eda Binary files /dev/null and b/dspace-api/src/test/resources/org/dspace/app/mediafilter/cat-rotated-90.jpg differ diff --git a/dspace-api/src/test/resources/org/dspace/app/mediafilter/cat.jpg b/dspace-api/src/test/resources/org/dspace/app/mediafilter/cat.jpg new file mode 100644 index 00000000000..b282aa970c8 Binary files /dev/null and b/dspace-api/src/test/resources/org/dspace/app/mediafilter/cat.jpg differ diff --git a/dspace-iiif/pom.xml b/dspace-iiif/pom.xml index 79d8412c17b..c3a4e248485 100644 --- a/dspace-iiif/pom.xml +++ b/dspace-iiif/pom.xml @@ -15,7 +15,7 @@ org.dspace dspace-parent - 7.6.5 + 7.6.7 .. diff --git a/dspace-oai/pom.xml b/dspace-oai/pom.xml index a46ee7ba511..64612f9d5f4 100644 --- a/dspace-oai/pom.xml +++ b/dspace-oai/pom.xml @@ -8,7 +8,7 @@ dspace-parent org.dspace - 7.6.5 + 7.6.7 .. 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 65f3b733cd5..5aa51928750 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 @@ -190,9 +190,9 @@ private int index(Date last) throws DSpaceSolrIndexerException, IOException { .findInArchiveOrWithdrawnDiscoverableModifiedSince(context, last); Iterator nonDiscoverableChangedItems = itemService .findInArchiveOrWithdrawnNonDiscoverableModifiedSince(context, last); + int total = this.index(discoverableChangedItems, true) + this.index(nonDiscoverableChangedItems, true); Iterator possiblyChangedItems = getItemsWithPossibleChangesBefore(last); - return this.index(discoverableChangedItems) + this.index(nonDiscoverableChangedItems) - + this.index(possiblyChangedItems); + return total + this.index(possiblyChangedItems, false); } catch (SQLException ex) { throw new DSpaceSolrIndexerException(ex.getMessage(), ex); } @@ -262,7 +262,7 @@ private int indexAll() throws DSpaceSolrIndexerException { null); Iterator nonDiscoverableItems = itemService .findInArchiveOrWithdrawnNonDiscoverableModifiedSince(context, null); - return this.index(discoverableItems) + this.index(nonDiscoverableItems); + return this.index(discoverableItems, true) + this.index(nonDiscoverableItems, true); } catch (SQLException ex) { throw new DSpaceSolrIndexerException(ex.getMessage(), ex); } @@ -305,7 +305,7 @@ private boolean checkIfVisibleInOAI(Item item) throws IOException { } } - private int index(Iterator iterator) throws DSpaceSolrIndexerException { + private int index(Iterator iterator, boolean uncacheEntities) throws DSpaceSolrIndexerException { try { int i = 0; int batchSize = configurationService.getIntProperty("oai.import.batch.size", 1000); @@ -334,10 +334,12 @@ private int index(Iterator iterator) throws DSpaceSolrIndexerException { server.add(list); server.commit(); list.clear(); - try { - context.uncacheEntities(); - } catch (SQLException ex) { - log.error("Error uncaching entities", ex); + if (uncacheEntities) { + try { + context.uncacheEntities(); + } catch (SQLException ex) { + log.error("Error uncaching entities", ex); + } } } } diff --git a/dspace-oai/src/main/java/org/dspace/xoai/services/api/config/ConfigurationService.java b/dspace-oai/src/main/java/org/dspace/xoai/services/api/config/ConfigurationService.java index bc6083166ba..997e08adec8 100644 --- a/dspace-oai/src/main/java/org/dspace/xoai/services/api/config/ConfigurationService.java +++ b/dspace-oai/src/main/java/org/dspace/xoai/services/api/config/ConfigurationService.java @@ -15,4 +15,6 @@ public interface ConfigurationService { boolean getBooleanProperty(String module, String key, boolean defaultValue); boolean getBooleanProperty(String key, boolean defaultValue); + + void ensureRequiredConfiguration(); } diff --git a/dspace-oai/src/main/java/org/dspace/xoai/services/impl/config/DSpaceConfigurationService.java b/dspace-oai/src/main/java/org/dspace/xoai/services/impl/config/DSpaceConfigurationService.java index 67d4f09f5c8..0fb0c51defd 100644 --- a/dspace-oai/src/main/java/org/dspace/xoai/services/impl/config/DSpaceConfigurationService.java +++ b/dspace-oai/src/main/java/org/dspace/xoai/services/impl/config/DSpaceConfigurationService.java @@ -20,12 +20,18 @@ public class DSpaceConfigurationService implements ConfigurationService { * Initialize the OAI Configuration Service */ public DSpaceConfigurationService() { - // Check the DSpace ConfigurationService for required OAI-PMH settings. - // If they do not exist, set sane defaults as needed. + ensureRequiredConfiguration(); + } - // Per OAI Spec, "oai.identifier.prefix" should be the hostname / domain name of the site. - // This configuration is needed by the [dspace]/config/crosswalks/oai/description.xml template, so if - // unspecified we will dynamically set it to the hostname of the "dspace.ui.url" configuration. + /** + * Check the DSpace ConfigurationService for required OAI-PMH settings. + * If they do not exist, set sane defaults as needed. + *
+ * Per OAI Spec, "oai.identifier.prefix" should be the hostname / domain name of the site. + * This configuration is needed by the [dspace]/config/crosswalks/oai/description.xml template, so if + * unspecified we will dynamically set it to the hostname of the "dspace.ui.url" configuration. + */ + public void ensureRequiredConfiguration() { if (!configurationService.hasProperty("oai.identifier.prefix")) { configurationService.setProperty("oai.identifier.prefix", Utils.getHostName(configurationService.getProperty("dspace.ui.url"))); diff --git a/dspace-oai/src/main/java/org/dspace/xoai/services/impl/xoai/DSpaceRepositoryConfiguration.java b/dspace-oai/src/main/java/org/dspace/xoai/services/impl/xoai/DSpaceRepositoryConfiguration.java index 2a000f43ea0..79b04094740 100644 --- a/dspace-oai/src/main/java/org/dspace/xoai/services/impl/xoai/DSpaceRepositoryConfiguration.java +++ b/dspace-oai/src/main/java/org/dspace/xoai/services/impl/xoai/DSpaceRepositoryConfiguration.java @@ -133,6 +133,7 @@ public String getRepositoryName() { @Override public List getDescription() { + configurationService.ensureRequiredConfiguration(); List result = new ArrayList(); String descriptionFile = configurationService.getProperty("oai.description.file"); if (descriptionFile == null) { diff --git a/dspace-oai/src/main/java/org/dspace/xoai/util/ItemUtils.java b/dspace-oai/src/main/java/org/dspace/xoai/util/ItemUtils.java index 4c3e159e847..80cb4d4816c 100644 --- a/dspace-oai/src/main/java/org/dspace/xoai/util/ItemUtils.java +++ b/dspace-oai/src/main/java/org/dspace/xoai/util/ItemUtils.java @@ -15,6 +15,7 @@ import java.util.List; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Pattern; import com.lyncode.xoai.dataprovider.xml.xoai.Element; import com.lyncode.xoai.dataprovider.xml.xoai.Metadata; @@ -212,7 +213,7 @@ private static Element createBundlesElement(Context context, Item item, AtomicBo bitstream.getField().add(createValue("name", name)); } if (oname != null) { - bitstream.getField().add(createValue("originalName", name)); + bitstream.getField().add(createValue("originalName", oname)); } if (description != null) { bitstream.getField().add(createValue("description", description)); @@ -241,6 +242,46 @@ private static Element createBundlesElement(Context context, Item item, AtomicBo return bundles; } + /** + * Matches everything XML 1.0 forbids outright, in three alternations: + *
    + *
  1. C0 controls other than tab, LF and CR, plus the non-characters U+FFFE and U+FFFF;
  2. + *
  3. a high surrogate not followed by a low surrogate;
  4. + *
  5. a low surrogate not preceded by a high surrogate.
  6. + *
+ * See https://www.w3.org/TR/xml/#charsets. Unpaired surrogates matter in practice: a truncated + * 4-byte character in ingested metadata makes the StAX writer throw "Broken surrogate pair", and + * because XOAI.index() catches that per item the record is silently dropped from the OAI index. + */ + private static final Pattern INVALID_XML10_CHARS = Pattern.compile( + "[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\uFFFE\\uFFFF]" + + "|[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])" + + "|(? + * NOTE: this deliberately REMOVES illegal characters rather than escaping the string. The value + * returned here is handed to the XOAI serializer, which performs XML escaping itself (every text + * event goes through {@code XMLStreamWriter.writeCharacters}), so escaping here as well would + * double-escape every value containing &, <, >, " or ' — a harvester would then read the + * literal text "&lt;" instead of a "<" character. That silently corrupts every OAI format + * built on the xoai document, including the cmdi and olac formats CLARIN/LINDAT is aggregated + * through. + *

+ * The removal set must stay equivalent to what {@code StringEscapeUtils.escapeXml10} removed — + * notably including unpaired surrogates — otherwise items carrying them fail to serialize and + * drop out of the OAI index entirely. + * @param value The string to sanitize. + * @return A sanitized string, or null if the input was null. + */ + private static String sanitize(String value) { + if (value == null) { + return null; + } + return INVALID_XML10_CHARS.matcher(value).replaceAll(""); + } + private static Element createLicenseElement(Context context, Item item) throws SQLException, AuthorizeException, IOException { Element license = create("license"); @@ -314,7 +355,7 @@ private static void fillSchemaElement(Element schema, MetadataValue val) throws valueElem = language; } - valueElem.getField().add(createValue("value", val.getValue())); + valueElem.getField().add(createValue("value", sanitize(val.getValue()))); if (val.getAuthority() != null) { valueElem.getField().add(createValue("authority", val.getAuthority())); if (val.getConfidence() != Choices.CF_NOVALUE) { diff --git a/dspace-rdf/pom.xml b/dspace-rdf/pom.xml index d32a00bf626..480b101177b 100644 --- a/dspace-rdf/pom.xml +++ b/dspace-rdf/pom.xml @@ -9,7 +9,7 @@ org.dspace dspace-parent - 7.6.5 + 7.6.7 .. diff --git a/dspace-rest/pom.xml b/dspace-rest/pom.xml index 64b798c58a1..f501de21f52 100644 --- a/dspace-rest/pom.xml +++ b/dspace-rest/pom.xml @@ -3,7 +3,7 @@ org.dspace dspace-rest war - 7.6.5 + 7.6.7 DSpace (Deprecated) REST Webapp DSpace RESTful Web Services API. NOTE: this REST API is DEPRECATED. Please consider using the REST API in the dspace-server-webapp instead! @@ -12,7 +12,7 @@ org.dspace dspace-parent - 7.6.5 + 7.6.7 .. diff --git a/dspace-server-webapp/pom.xml b/dspace-server-webapp/pom.xml index 8dbd6fff00e..aa059b48f1c 100644 --- a/dspace-server-webapp/pom.xml +++ b/dspace-server-webapp/pom.xml @@ -15,7 +15,7 @@ org.dspace dspace-parent - 7.6.5 + 7.6.7 .. @@ -439,6 +439,13 @@ + + + + org.dspace + dspace-iiif + + org.dspace dspace-api @@ -458,10 +465,6 @@ - - org.dspace - dspace-iiif - org.dspace dspace-oai @@ -514,6 +517,7 @@ net.minidev json-smart + 2.6.0 diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/Application.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/Application.java index daa43ce2669..1e6d9941b6b 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/Application.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/Application.java @@ -11,8 +11,11 @@ import java.io.IOException; import java.sql.SQLException; +import java.time.ZoneOffset; import java.util.List; +import java.util.TimeZone; import java.util.function.Predicate; +import javax.annotation.PostConstruct; import javax.servlet.Filter; import org.dspace.app.rest.filter.DSpaceRequestContextFilter; @@ -284,4 +287,12 @@ public void addArgumentResolvers(@NonNull List ar } }; } + + @PostConstruct + public void setDefaultTimezone() { + // Set the default timezone in Spring Boot to UTC. + // This ensures that Spring Boot doesn't attempt to change the timezone of dates that are read from the + // database (via Hibernate). We store all dates in the database as UTC. + TimeZone.setDefault(TimeZone.getTimeZone(ZoneOffset.UTC)); + } } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamByHandleRestController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamByHandleRestController.java index 36cdffc9b20..92ae2ec75cb 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamByHandleRestController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamByHandleRestController.java @@ -12,8 +12,6 @@ import java.io.IOException; import java.io.InputStream; import java.net.URI; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; import java.sql.SQLException; import java.util.List; import java.util.Objects; @@ -42,6 +40,7 @@ import org.dspace.storage.bitstore.S3BitStoreService; import org.dspace.storage.bitstore.service.S3DirectDownloadService; import org.dspace.usage.UsageEvent; +import org.dspace.util.ContentDispositionUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; @@ -288,16 +287,9 @@ private void redirectToS3DownloadUrl(String bitName, String bitInternalId, * @return the Content-Disposition header value */ private String buildContentDisposition(String name) { - // RFC 5987 percent-encoding for filename* - String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8) - .replace("+", "%20"); - // ASCII fallback: replace non-ASCII chars with underscore, escape quotes. - // Modern clients use filename* (RFC 5987 / RFC 6266) with real UTF-8 name. - String asciiFallback = name.replaceAll("[^\\x20-\\x7E]", "_") - .replace("\\", "\\\\") - .replace("\"", "\\\""); - return String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s", - asciiFallback, encoded); + // Delegates to the shared helper; this used to be one of two divergent copies of the same logic, + // and the other one - in the S3 presigned-URL path - was the incorrect one. + return ContentDispositionUtils.build(ContentDispositionUtils.ATTACHMENT, name); } /** 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 0c4bf35f45f..ec5ddf1d70d 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 @@ -23,7 +23,6 @@ import javax.ws.rs.core.Response; import org.apache.catalina.connector.ClientAbortException; -import org.apache.commons.collections4.ListUtils; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.Logger; import org.dspace.app.rest.converter.ConverterService; @@ -126,7 +125,7 @@ public ResponseEntity retrieve(@PathVariable UUID uuid, HttpServletResponse resp Bitstream bit = bitstreamService.find(context, uuid); EPerson currentUser = context.getCurrentUser(); - if (bit == null) { + if (bit == null || bit.isDeleted()) { response.sendError(HttpServletResponse.SC_NOT_FOUND); return null; } @@ -183,12 +182,16 @@ public ResponseEntity retrieve(@PathVariable UUID uuid, HttpServletResponse resp httpHeadersInitializer.withLastModified(lastModified); } - //Determine if we need to send the file as a download or if the browser can open it inline - //The file will be downloaded if its size is larger than the configured threshold, - //or if its mimetype/extension appears in the "webui.content_disposition_format" config - long dispositionThreshold = configurationService.getLongProperty("webui.content_disposition_threshold"); - if ((dispositionThreshold >= 0 && filesize > dispositionThreshold) - || checkFormatForContentDisposition(format)) { + // Determine if we need to send the file as a download or if the browser can open it inline. + // By default, all files will be downloaded as that is more secure. File formats will only be opened inline + // if they are listed in the "webui.content_disposition_inline" config and size is less than the + // configured "webui.content_disposition_threshold" (default = 8MB) + long dispositionThreshold = configurationService.getLongProperty("webui.content_disposition_threshold", + 8388608); + if (checkFormatForContentDispositionInline(format) && + filesize <= dispositionThreshold) { + httpHeadersInitializer.withDisposition(HttpHeadersInitializer.CONTENT_DISPOSITION_INLINE); + } else { httpHeadersInitializer.withDisposition(HttpHeadersInitializer.CONTENT_DISPOSITION_ATTACHMENT); } @@ -247,8 +250,11 @@ private ResponseEntity redirectToS3DownloadUrl(HttpHeaders httpHeaders, String b // Generate a presigned URL for the bitstream with a configurable expiration time int expirationTime = configurationService.getIntProperty("s3.download.direct.expiration", 3600); log.debug("Generating presigned URL with expiration time of {} seconds", expirationTime); - String presignedUrl = - s3DirectDownloadService.generatePresignedUrl(bucket, bitstreamPath, expirationTime, bitName); + // Serve the same Content-Disposition the non-redirect path would have sent, so that enabling + // direct downloads does not silently turn every inline preview into a forced download. + String contentDisposition = httpHeaders.getFirst(HttpHeaders.CONTENT_DISPOSITION); + String presignedUrl = s3DirectDownloadService.generatePresignedUrl( + bucket, bitstreamPath, expirationTime, bitName, contentDisposition); if (StringUtils.isBlank(presignedUrl)) { throw new InternalServerErrorException("Failed to generate presigned URL for bitstream: " @@ -283,48 +289,48 @@ private boolean isNotAnErrorResponse(HttpServletResponse response) { } /** - * Check if a Bitstream of the specified format should always be downloaded (i.e. "content-disposition: attachment") - * or can be opened inline (i.e. "content-disposition: inline"). + * Check if a Bitstream of the specified format should be opened inline (i.e. "content-disposition: inline") + * instead of the default behavior of always downloading (i.e. "content-disposition: attachment"). *

* NOTE that downloading via "attachment" is more secure, as the user's browser will not attempt to process or * display the file. But, downloading via "inline" may be seen as more user-friendly for common formats. * @param format BitstreamFormat - * @return true if always download ("attachment"). false if can be opened inline ("inline") + * @return true if format is configured to be opened inline ("inline"). false if always download ("attachment") */ - private boolean checkFormatForContentDisposition(BitstreamFormat format) { + private boolean checkFormatForContentDispositionInline(BitstreamFormat format) { // Undefined or Unknown formats should ALWAYS be downloaded for additional security. if (format == null || format.getSupportLevel() == BitstreamFormat.UNKNOWN) { - return true; + return false; } - // Load additional formats configured to require download - List configuredFormats = List.of(configurationService. - getArrayProperty("webui.content_disposition_format")); - - // If configuration includes "*", then all formats will always be downloaded. - if (configuredFormats.contains("*")) { - return true; + // Return false for BANNED inline formats. Some formats, especially XML / HTML / Javascript based formats, + // when loaded inline may be susceptible to XSS attacks. Therefore, we will refuse to allow those formats to be + // displayed inline for security purposes. + // NOTE: "+xml" in this list will match any MIME Type that ends in "+xml", as those are XML-based formats. + List bannedInlineFormats = List.of("text/html", "text/javascript", "text/xml", "application/xml", + "+xml"); + for (String bannedInlineFormat : bannedInlineFormats) { + // If our format MIME Type contains one of the banned inline formats, we refuse to display it inline + if (format.getMIMEType().contains(bannedInlineFormat)) { + return false; + } } - // Define a download list of formats which DSpace forces to ALWAYS be downloaded. - // These formats can embed JavaScript which may be run in the user's browser if the file is opened inline. - // Therefore, DSpace blocks opening these formats inline as it could be used for an XSS attack. - List downloadOnlyFormats = List.of("text/html", "text/javascript", "text/xml", "rdf"); - - // Combine our two lists - List formats = ListUtils.union(downloadOnlyFormats, configuredFormats); + // Load formats configured to allow inline display + List formats = List.of(configurationService. + getArrayProperty("webui.content_disposition_inline")); // See if the passed in format's MIME type or file extension is listed. - boolean download = formats.contains(format.getMIMEType()); - if (!download) { + boolean inline = formats.contains(format.getMIMEType()); + if (!inline) { for (String ext : format.getExtensions()) { if (formats.contains(ext)) { - download = true; + inline = true; break; } } } - return download; + return inline; } /** diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/ResourcePolicyEPersonReplaceRestController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/ResourcePolicyEPersonReplaceRestController.java index a35e5fdcdd2..f04935b0c8f 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/ResourcePolicyEPersonReplaceRestController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/ResourcePolicyEPersonReplaceRestController.java @@ -79,6 +79,7 @@ public ResponseEntity> replaceEPersonOfResourcePolicy(@Pa EPerson newEPerson = (EPerson) dsoList.get(0); resourcePolicy.setEPerson(newEPerson); provenanceService.updateResourcePolicy(context, resourcePolicy); + resourcePolicyService.update(context, resourcePolicy); context.commit(); return ControllerUtils.toEmptyResponse(HttpStatus.NO_CONTENT); } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/ResourcePolicyGroupReplaceRestController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/ResourcePolicyGroupReplaceRestController.java index 97cfd49408b..5e52dbc7659 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/ResourcePolicyGroupReplaceRestController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/ResourcePolicyGroupReplaceRestController.java @@ -79,6 +79,7 @@ public ResponseEntity> replaceGroupOfResourcePolicy(@Path Group newGroup = (Group) dsoList.get(0); resourcePolicy.setGroup(newGroup); provenanceService.updateResourcePolicy(context, resourcePolicy); + resourcePolicyService.update(context, resourcePolicy); context.commit(); return ControllerUtils.toEmptyResponse(HttpStatus.NO_CONTENT); } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/authorization/impl/EditItemFeature.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/authorization/impl/EditItemFeature.java index 5c605daaf40..d938cd5a335 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/authorization/impl/EditItemFeature.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/authorization/impl/EditItemFeature.java @@ -40,7 +40,7 @@ public class EditItemFeature implements AuthorizationFeature { @Override public boolean isAuthorized(Context context, BaseObjectRest object) throws SQLException, SearchServiceException { if (object instanceof SiteRest) { - return itemService.countItemsWithEdit(context) > 0; + return itemService.countItemsWithEdit(context, "") > 0; } else if (object instanceof ItemRest) { Item item = (Item) utils.getDSpaceAPIObjectFromRest(context, object); return authService.authorizeActionBoolean(context, item, Constants.WRITE); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/authorization/impl/SubmitFeature.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/authorization/impl/SubmitFeature.java index 3793928fb0f..599bdb64117 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/authorization/impl/SubmitFeature.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/authorization/impl/SubmitFeature.java @@ -43,7 +43,7 @@ public class SubmitFeature implements AuthorizationFeature { public boolean isAuthorized(Context context, BaseObjectRest object) throws SQLException, SearchServiceException { if (object instanceof SiteRest) { // Check whether the user has permission to add to any collection - return collectionService.countCollectionsWithSubmit("", context, null) > 0; + return collectionService.countCollectionsWithSubmit(context, "", null) > 0; } else if (object instanceof CollectionRest) { // Check whether the user has permission to add to the given collection Collection collection = (Collection) utils.getDSpaceAPIObjectFromRest(context, object); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/SubmissionDefinitionConverter.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/SubmissionDefinitionConverter.java index a8454552046..65f43096df4 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/SubmissionDefinitionConverter.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/SubmissionDefinitionConverter.java @@ -7,24 +7,16 @@ */ package org.dspace.app.rest.converter; -import java.sql.SQLException; import java.util.LinkedList; import java.util.List; -import java.util.stream.Collectors; -import javax.servlet.http.HttpServletRequest; import org.apache.logging.log4j.Logger; -import org.dspace.app.rest.model.CollectionRest; import org.dspace.app.rest.model.SubmissionDefinitionRest; import org.dspace.app.rest.model.SubmissionSectionRest; import org.dspace.app.rest.projection.Projection; import org.dspace.app.rest.submit.DataProcessingStep; -import org.dspace.app.rest.utils.ContextUtil; import org.dspace.app.util.SubmissionConfig; -import org.dspace.app.util.SubmissionConfigReaderException; import org.dspace.app.util.SubmissionStepConfig; -import org.dspace.content.Collection; -import org.dspace.core.Context; import org.dspace.services.RequestService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; @@ -76,20 +68,6 @@ public SubmissionDefinitionRest convert(SubmissionConfig obj, Projection project } } - HttpServletRequest request = requestService.getCurrentRequest().getHttpServletRequest(); - Context context = null; - try { - context = ContextUtil.obtainContext(request); - List collections = panelConverter.getSubmissionConfigService() - .getCollectionsBySubmissionConfig(context, - obj.getSubmissionName()); - DSpaceConverter cc = converter.getConverter(Collection.class); - List collectionsRest = collections.stream().map((collection) -> - cc.convert(collection, projection)).collect(Collectors.toList()); - sd.setCollections(collectionsRest); - } catch (SQLException | IllegalStateException | SubmissionConfigReaderException e) { - log.error(e.getMessage(), e); - } sd.setPanels(panels); return sd; } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/CollectionRestRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/CollectionRestRepository.java index 1498a630504..e6088223dd6 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/CollectionRestRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/CollectionRestRepository.java @@ -185,7 +185,7 @@ public Page findSubmitAuthorizedByCommunity( List collections = cs.findCollectionsWithSubmit(q, context, com, Math.toIntExact(pageable.getOffset()), Math.toIntExact(pageable.getPageSize())); - int tot = cs.countCollectionsWithSubmit(q, context, com); + int tot = cs.countCollectionsWithSubmit(context, q, com); return converter.toRestPage(collections, pageable, tot , utils.obtainProjection()); } catch (SQLException | SearchServiceException e) { throw new RuntimeException(e.getMessage(), e); @@ -200,7 +200,7 @@ public Page findSubmitAuthorized(@Parameter(value = "query") Str List collections = cs.findCollectionsWithSubmit(q, context, null, Math.toIntExact(pageable.getOffset()), Math.toIntExact(pageable.getPageSize())); - int tot = cs.countCollectionsWithSubmit(q, context, null); + int tot = cs.countCollectionsWithSubmit(context, q, null); return converter.toRestPage(collections, pageable, tot, utils.obtainProjection()); } catch (SQLException e) { throw new RuntimeException(e.getMessage(), e); @@ -211,14 +211,33 @@ public Page findSubmitAuthorized(@Parameter(value = "query") Str @SearchRestMethod(name = "findAdminAuthorized") public Page findAdminAuthorized ( Pageable pageable, @Parameter(value = "query") String query) { + return findAuthorized(pageable, Constants.ADMIN, query); + } + + /** + * Returns Collections for which the current user has 'edit' privileges. + * + * @param pageable The pagination information + * @param query The query used in the lookup + * @return + */ + @PreAuthorize("hasAuthority('AUTHENTICATED')") + @SearchRestMethod(name = "findEditAuthorized") + public Page findEditAuthorized ( + Pageable pageable, @Parameter(value = "query") String query) { + return findAuthorized(pageable, Constants.WRITE, query); + } + + private Page findAuthorized(Pageable pageable, int action, String query) { try { Context context = obtainContext(); - List collections = authorizeService.findAdminAuthorizedCollection(context, query, + List collections = authorizeService.findAuthorizedCollectionByAction(context, query, + action, Math.toIntExact(pageable.getOffset()), Math.toIntExact(pageable.getPageSize())); - long tot = authorizeService.countAdminAuthorizedCollection(context, query); - return converter.toRestPage(collections, pageable, tot , utils.obtainProjection()); - } catch (SearchServiceException | SQLException e) { + long tot = authorizeService.countAuthorizedCollectionByAction(context, query, action); + return converter.toRestPage(collections, pageable, tot, utils.obtainProjection()); + } catch (SearchServiceException e) { throw new RuntimeException(e.getMessage(), e); } } @@ -245,10 +264,10 @@ public Page findSubmitAuthorizedByEntityType( if (entityType == null) { throw new ResourceNotFoundException("There was no entityType found with label: " + entityTypeLabel); } - List collections = cs.findCollectionsWithSubmit(query, context, null, entityTypeLabel, + List collections = cs.findCollectionsWithSubmit(context, query,null, entityTypeLabel, Math.toIntExact(pageable.getOffset()), Math.toIntExact(pageable.getPageSize())); - int tot = cs.countCollectionsWithSubmit(query, context, null, entityTypeLabel); + int tot = cs.countCollectionsWithSubmit(context, query,null, entityTypeLabel); return converter.toRestPage(collections, pageable, tot, utils.obtainProjection()); } catch (SQLException e) { throw new RuntimeException(e.getMessage(), e); @@ -282,10 +301,10 @@ public Page findSubmitAuthorizedByCommunityAndEntityType( throw new ResourceNotFoundException( CommunityRest.CATEGORY + "." + CommunityRest.NAME + " with id: " + communityUuid + " not found"); } - List collections = cs.findCollectionsWithSubmit(query, context, community, entityTypeLabel, + List collections = cs.findCollectionsWithSubmit(context, query, community, entityTypeLabel, Math.toIntExact(pageable.getOffset()), Math.toIntExact(pageable.getPageSize())); - int total = cs.countCollectionsWithSubmit(query, context, community, entityTypeLabel); + int total = cs.countCollectionsWithSubmit(context, query, community, entityTypeLabel); return converter.toRestPage(collections, pageable, total, utils.obtainProjection()); } catch (SQLException | SearchServiceException e) { throw new RuntimeException(e.getMessage(), e); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/CommunityRestRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/CommunityRestRepository.java index 3acbccb6cf0..3f3f10b4d37 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/CommunityRestRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/CommunityRestRepository.java @@ -38,6 +38,7 @@ import org.dspace.content.Community; import org.dspace.content.service.BitstreamService; import org.dspace.content.service.CommunityService; +import org.dspace.core.Constants; import org.dspace.core.Context; import org.dspace.discovery.DiscoverQuery; import org.dspace.discovery.DiscoverResult; @@ -220,12 +221,31 @@ public Page findAllTop(Pageable pageable) { @SearchRestMethod(name = "findAdminAuthorized") public Page findAdminAuthorized ( Pageable pageable, @Parameter(value = "query") String query) { + return findAuthorized(pageable, Constants.ADMIN, query); + } + + @PreAuthorize("hasAuthority('AUTHENTICATED')") + @SearchRestMethod(name = "findEditAuthorized") + public Page findEditAuthorized ( + Pageable pageable, @Parameter(value = "query") String query) { + return findAuthorized(pageable, Constants.WRITE, query); + } + + @PreAuthorize("hasAuthority('AUTHENTICATED')") + @SearchRestMethod(name = "findAddAuthorized") + public Page findAddAuthorized ( + Pageable pageable, @Parameter(value = "query") String query) { + return findAuthorized(pageable, Constants.ADD, query); + } + + private Page findAuthorized(Pageable pageable, int action, String query) { try { Context context = obtainContext(); - List communities = authorizeService.findAdminAuthorizedCommunity(context, query, + List communities = authorizeService.findAuthorizedCommunityByAction(context, query, + action, Math.toIntExact(pageable.getOffset()), Math.toIntExact(pageable.getPageSize())); - long tot = authorizeService.countAdminAuthorizedCommunity(context, query); + long tot = authorizeService.countAuthorizedCommunityByAction(context, query, action); return converter.toRestPage(communities, pageable, tot , utils.obtainProjection()); } catch (SearchServiceException | SQLException e) { throw new RuntimeException(e.getMessage(), e); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ItemRestRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ItemRestRepository.java index e1ab6d2b2fb..468f33137ab 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ItemRestRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ItemRestRepository.java @@ -52,6 +52,7 @@ import org.dspace.content.service.WorkspaceItemService; import org.dspace.content.service.clarin.ClarinItemService; import org.dspace.core.Context; +import org.dspace.discovery.SearchServiceException; import org.dspace.util.UUIDUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; @@ -375,6 +376,27 @@ public Bundle addBundleToItem(Context context, Item item, BundleRest bundleRest) return bundle; } + /** + * Method to find the items for which the current user has editing rights. + * + * @param query Query string + * @param pageable Pagination information + * @return Page of Items (REST representation) for which the current user has editing rights + * @throws SearchServiceException + */ + @PreAuthorize("hasAuthority('AUTHENTICATED')") + @SearchRestMethod(name = "findEditAuthorized") + public Page findEditAuthorized(@Parameter(value = "query") String query, + Pageable pageable) + throws SearchServiceException { + Context context = obtainContext(); + List items = itemService.findItemsWithEdit(context, query, + Math.toIntExact(pageable.getOffset()), + Math.toIntExact(pageable.getPageSize())); + int tot = itemService.countItemsWithEdit(context, query); + return converter.toRestPage(items, pageable, tot, utils.obtainProjection()); + } + @Override protected ItemRest createAndReturn(Context context, List stringList) throws AuthorizeException, SQLException, RepositoryMethodNotImplementedException { diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/AuthorizeServicePermissionEvaluatorPlugin.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/AuthorizeServicePermissionEvaluatorPlugin.java index f0b44187c59..7e87a1a613e 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/AuthorizeServicePermissionEvaluatorPlugin.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/AuthorizeServicePermissionEvaluatorPlugin.java @@ -13,6 +13,7 @@ import org.dspace.app.rest.utils.ContextUtil; import org.dspace.authorize.service.AuthorizeService; +import org.dspace.content.Bitstream; import org.dspace.content.DSpaceObject; import org.dspace.content.Item; import org.dspace.content.factory.ContentServiceFactory; @@ -86,6 +87,10 @@ public boolean hasDSpacePermission(Authentication authentication, Serializable t return true; } + if (dSpaceObject instanceof Bitstream && ((Bitstream) dSpaceObject).isDeleted()) { + return true; // Let downstream REST layer handle with 404 + } + if (dSpaceObject instanceof Item) { Item item = (Item) dSpaceObject; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/BitstreamMetadataReadPermissionEvaluatorPlugin.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/BitstreamMetadataReadPermissionEvaluatorPlugin.java index b4b08c668b0..d15412cd201 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/BitstreamMetadataReadPermissionEvaluatorPlugin.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/BitstreamMetadataReadPermissionEvaluatorPlugin.java @@ -73,6 +73,9 @@ public boolean hasPermission(Authentication authentication, Serializable targetI } public boolean metadataReadPermissionOnBitstream(Context context, Bitstream bitstream) throws SQLException { + if (bitstream.isDeleted()) { + return true; // Let downstream REST layer handle with 404 + } if (authorizeService.isAdmin(context, bitstream)) { // Is Admin on bitstream return true; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/OrcidLoginFilter.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/OrcidLoginFilter.java index 9fdef6b050f..49dd7b87282 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/OrcidLoginFilter.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/OrcidLoginFilter.java @@ -87,6 +87,7 @@ protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServle String baseRediredirectUrl = configurationService.getProperty("dspace.ui.url"); String redirectUrl = baseRediredirectUrl + "/error?status=401&code=orcid.generic-error"; response.sendRedirect(redirectUrl); // lgtm [java/unvalidated-url-redirection] + this.closeOpenContext(request); } else { super.unsuccessfulAuthentication(request, response, failed); } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/StatelessLoginFilter.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/StatelessLoginFilter.java index c95fce71c42..31f3d5d460b 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/StatelessLoginFilter.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/StatelessLoginFilter.java @@ -8,11 +8,14 @@ package org.dspace.app.rest.security; import java.io.IOException; +import java.sql.SQLException; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import org.dspace.app.rest.utils.ContextUtil; +import org.dspace.core.Context; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.authentication.AuthenticationManager; @@ -122,6 +125,27 @@ protected void unsuccessfulAuthentication(HttpServletRequest request, response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authentication failed!"); log.error("Authentication failed (status:{})", HttpServletResponse.SC_UNAUTHORIZED, failed); + this.closeOpenContext(request); + } + + /** + * Manually closes the open {@link Context} if one exists. We need to do this manually because + * {@link #continueChainBeforeSuccessfulAuthentication} is {@code false} by default, which prevents the + * {@link org.dspace.app.rest.filter.DSpaceRequestContextFilter} from being called. Without this call, the request + * would leave an open database connection. + * + * @param request The current request. + */ + protected void closeOpenContext(HttpServletRequest request) { + if (ContextUtil.isContextAvailable(request)) { + try (Context context = ContextUtil.obtainContext(request)) { + if (context != null && context.isValid()) { + context.complete(); + } + } catch (SQLException e) { + throw new RuntimeException(e); + } + } } } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/jwt/JWTTokenHandler.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/jwt/JWTTokenHandler.java index 6beab1aa853..5ea9af25dac 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/jwt/JWTTokenHandler.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/jwt/JWTTokenHandler.java @@ -65,7 +65,7 @@ public abstract class JWTTokenHandler { private List jwtClaimProviders; @Autowired - private ConfigurationService configurationService; + protected ConfigurationService configurationService; @Autowired private EPersonClaimProvider ePersonClaimProvider; @@ -79,6 +79,13 @@ public abstract class JWTTokenHandler { private String generatedJwtKey; private String generatedEncryptionKey; + /** + * Get the default expiration period for this handler if not + * defined in configuration. + * @return default expiration period if not explicitly defined in configuration + */ + public abstract long getExpirationPeriod(); + /** * Get the configuration property key for the token secret. * @return the configuration property key @@ -219,10 +226,6 @@ public String getJwtKey() { return secret; } - public long getExpirationPeriod() { - return configurationService.getLongProperty(getTokenExpirationConfigurationKey(), 1800000); - } - public boolean isEncryptionEnabled() { return configurationService.getBooleanProperty(getEncryptionEnabledConfigurationKey(), false); } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/jwt/LoginJWTTokenHandler.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/jwt/LoginJWTTokenHandler.java index 1fad8416580..46877b03c3c 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/jwt/LoginJWTTokenHandler.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/jwt/LoginJWTTokenHandler.java @@ -15,6 +15,17 @@ */ @Component public class LoginJWTTokenHandler extends JWTTokenHandler { + + /** + * Default expiration period for login tokens in milliseconds + */ + private static final long DEFAULT_EXPIRATION_PERIOD = 1800000; + + @Override + public long getExpirationPeriod() { + return configurationService.getLongProperty(getTokenExpirationConfigurationKey(), DEFAULT_EXPIRATION_PERIOD); + } + @Override protected String getTokenSecretConfigurationKey() { return "jwt.login.token.secret"; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/jwt/ShortLivedJWTTokenHandler.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/jwt/ShortLivedJWTTokenHandler.java index fc4ab39407a..177e84ed5a8 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/jwt/ShortLivedJWTTokenHandler.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/security/jwt/ShortLivedJWTTokenHandler.java @@ -28,6 +28,11 @@ @Component public class ShortLivedJWTTokenHandler extends JWTTokenHandler { + /** + * Default expiration period for short-lived tokens in milliseconds + */ + private static final long DEFAULT_EXPIRATION_PERIOD = 2000; + /** * Determine if current JWT is valid for the given EPerson object. * To be valid, current JWT *must* have been signed by the EPerson and not be expired. @@ -67,6 +72,11 @@ protected EPerson updateSessionSalt(final Context context, final Date previousLo return context.getCurrentUser(); } + @Override + public long getExpirationPeriod() { + return configurationService.getLongProperty(getTokenExpirationConfigurationKey(), DEFAULT_EXPIRATION_PERIOD); + } + @Override protected String getTokenSecretConfigurationKey() { return "jwt.shortLived.token.secret"; 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 de555617355..a4434e14c36 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 @@ -18,6 +18,7 @@ import org.dspace.app.rest.security.BitstreamMetadataReadPermissionEvaluatorPlugin; import org.dspace.app.rest.signposting.model.LinksetNode; import org.dspace.app.rest.signposting.processor.bitstream.BitstreamSignpostingProcessor; +import org.dspace.app.rest.signposting.processor.item.ItemLinksetProcessor; import org.dspace.app.rest.signposting.processor.item.ItemSignpostingProcessor; import org.dspace.app.rest.signposting.processor.metadata.MetadataSignpostingProcessor; import org.dspace.app.rest.signposting.service.LinksetService; @@ -25,9 +26,11 @@ import org.dspace.content.Bundle; import org.dspace.content.DSpaceObject; import org.dspace.content.Item; +import org.dspace.content.service.BundleService; import org.dspace.content.service.ItemService; import org.dspace.core.Constants; import org.dspace.core.Context; +import org.dspace.services.ConfigurationService; import org.dspace.utils.DSpace; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -40,12 +43,21 @@ public class LinksetServiceImpl implements LinksetService { private static final Logger log = LogManager.getLogger(LinksetServiceImpl.class); + @Autowired + private ConfigurationService configurationService; + @Autowired protected ItemService itemService; + @Autowired + protected BundleService bundleService; + @Autowired private BitstreamMetadataReadPermissionEvaluatorPlugin bitstreamMetadataReadPermissionEvaluatorPlugin; + @Autowired + ItemLinksetProcessor itemLinksetProcessor; + private final List bitstreamProcessors = new DSpace().getServiceManager() .getServicesByType(BitstreamSignpostingProcessor.class); @@ -74,10 +86,20 @@ public List createLinksetNodesForSingleLinkset( Context context, DSpaceObject object ) { + int itemBitstreamsLimit = configurationService.getIntProperty("signposting.item.bitstreams.limit", 10); + List linksetNodes = new ArrayList<>(); if (object.getType() == Constants.ITEM) { - for (ItemSignpostingProcessor processor : itemProcessors) { - processor.addLinkSetNodes(context, request, (Item) object, linksetNodes); + int itemBitstreamsCount = countItemBitstreams(context, (Item) object); + + // Do not include individual bitstream typed links if their number exceeds + // the limit in the configuration. + if (itemBitstreamsCount < itemBitstreamsLimit) { + for (ItemSignpostingProcessor processor : itemProcessors) { + processor.addLinkSetNodes(context, request, (Item) object, linksetNodes); + } + } else { + itemLinksetProcessor.addLinkSetNodes(context, request, (Item) object, linksetNodes); } } else if (object.getType() == Constants.BITSTREAM) { for (BitstreamSignpostingProcessor processor : bitstreamProcessors) { @@ -151,4 +173,17 @@ private Iterator getItemBitstreams(Context context, Item item) { throw new RuntimeException(e); } } + + private int countItemBitstreams(Context context, Item item) { + try { + int countBitstreams = 0; + List bundles = itemService.getBundles(item, Constants.DEFAULT_BUNDLE_NAME); + for (Bundle bundle: bundles) { + countBitstreams += bundleService.countBitstreams(context, bundle); + } + return countBitstreams; + } catch (SQLException e) { + throw new RuntimeException(e); + } + } } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/factory/impl/MetadataValueRemovePatchOperation.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/factory/impl/MetadataValueRemovePatchOperation.java index 1660a5455ae..18bc1df66c1 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/factory/impl/MetadataValueRemovePatchOperation.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/factory/impl/MetadataValueRemovePatchOperation.java @@ -11,6 +11,8 @@ import java.util.Arrays; import java.util.List; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.dspace.app.rest.model.MetadataValueRest; import org.dspace.content.DSpaceObject; import org.dspace.content.Item; @@ -27,6 +29,8 @@ public abstract class MetadataValueRemovePatchOperation extends RemovePatchOperation { + private static final Logger log = LogManager.getLogger(); + @Override protected Class getArrayClassForEvaluation() { return MetadataValueRest[].class; @@ -42,7 +46,12 @@ protected void deleteValue(Context context, DSO source, String target, int index List mm = getDSpaceObjectService().getMetadata(source, metadata[0], metadata[1], metadata[2], Item.ANY); if (index != -1) { - getDSpaceObjectService().removeMetadataValues(context, source, Arrays.asList(mm.get(index))); + if (index < mm.size()) { + getDSpaceObjectService().removeMetadataValues(context, source, Arrays.asList(mm.get(index))); + } else { + log.warn("value of index ({}) is out of range of the metadata value list of size {} (target: {})", + index, mm.size(), target); + } } else { getDSpaceObjectService().clearMetadata(context, source, metadata[0], metadata[1], metadata[2], Item.ANY); } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java index d68c710a3c7..67ad7202b5a 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java @@ -9,9 +9,11 @@ import static java.util.Objects.isNull; import static java.util.Objects.nonNull; -import static javax.mail.internet.MimeUtility.encodeText; import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.text.Normalizer; import java.util.Arrays; import java.util.Collections; import java.util.Objects; @@ -171,9 +173,16 @@ public HttpHeaders initialiseHeaders() throws IOException { // distposition may be null here if contentType is null if (!isNullOrEmpty(disposition)) { - httpHeaders.put(CONTENT_DISPOSITION, Collections.singletonList(String.format(CONTENT_DISPOSITION_FORMAT, - disposition, - encodeText(fileName)))); + String fallbackAsciiName = createFallbackAsciiName(this.fileName); + String encodedUtf8Name = createEncodedUtf8Name(this.fileName); + + String headerValue = String.format( + "%s; filename=\"%s\"; filename*=UTF-8''%s", + disposition, + fallbackAsciiName, + encodedUtf8Name + ); + httpHeaders.put(CONTENT_DISPOSITION, Collections.singletonList(headerValue)); } log.debug("Content-Disposition : {}", disposition); @@ -261,4 +270,41 @@ private static boolean matches(String matchHeader, String toMatch) { return Arrays.binarySearch(matchValues, toMatch) > -1 || Arrays.binarySearch(matchValues, "*") > -1; } + /** + * Creates a safe ASCII-only fallback filename by removing diacritics (accents) + * and replacing any remaining non-ASCII characters. + * E.g., "ä-ö-é.pdf" becomes "a-o-e.pdf". + * @param originalFilename The original filename. + * @return A string containing only ASCII characters. + */ + private String createFallbackAsciiName(String originalFilename) { + if (originalFilename == null) { + return ""; + } + String normalized = Normalizer.normalize(originalFilename, Normalizer.Form.NFD); + String withoutAccents = normalized.replaceAll("\\p{InCombiningDiacriticalMarks}+", ""); + return withoutAccents.replaceAll("[^\\x00-\\x7F]", ""); + } + + /** + * Creates a percent-encoded UTF-8 filename according to RFC 5987. + * This is for the `filename*` parameter. + * E.g., "ä ö é.pdf" becomes "%C3%A4%20%C3%B6%20%C3%A9.pdf". + * @param originalFilename The original filename. + * @return A percent-encoded string. + */ + private String createEncodedUtf8Name(String originalFilename) { + if (originalFilename == null) { + return ""; + } + try { + String encoded = URLEncoder.encode(originalFilename, StandardCharsets.UTF_8.toString()); + return encoded.replace("+", "%20"); + } catch (java.io.UnsupportedEncodingException e) { + // Fallback to a simple ASCII name if encoding fails. + log.error("UTF-8 encoding not supported, which should not happen.", e); + return createFallbackAsciiName(originalFilename); + } + } + } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/UsageReportUtils.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/UsageReportUtils.java index 4603569da84..53f4317808a 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/UsageReportUtils.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/UsageReportUtils.java @@ -27,6 +27,7 @@ import org.dspace.core.Constants; import org.dspace.core.Context; import org.dspace.handle.service.HandleService; +import org.dspace.services.ConfigurationService; import org.dspace.statistics.Dataset; import org.dspace.statistics.content.DatasetDSpaceObjectGenerator; import org.dspace.statistics.content.DatasetTimeGenerator; @@ -46,6 +47,9 @@ @Component public class UsageReportUtils { + @Autowired + private ConfigurationService configurationService; + @Autowired private HandleService handleService; @@ -135,13 +139,14 @@ public UsageReportRest createUsageReport(Context context, DSpaceObject dso, Stri */ private UsageReportRest resolveGlobalUsageReport(Context context) throws SQLException, IOException, ParseException, SolrServerException { + int topItemsLimit = configurationService.getIntProperty("usage-statistics.topItemsLimit", 10); + StatisticsListing statListing = new StatisticsListing( new StatisticsDataVisits()); - // Adding a new generator for our top 10 items without a name length delimiter + // Adding a new generator for our top n items without a name length delimiter DatasetDSpaceObjectGenerator dsoAxis = new DatasetDSpaceObjectGenerator(); - // TODO make max nr of top items (views wise)? Must be set - dsoAxis.addDsoChild(Constants.ITEM, 10, false, -1); + dsoAxis.addDsoChild(Constants.ITEM, topItemsLimit, false, -1); statListing.addDatasetGenerator(dsoAxis); Dataset dataset = statListing.getDataset(context, 1); @@ -182,7 +187,7 @@ private UsageReportRest resolveTotalVisits(Context context, DSpaceObject dso) UsageReportPointDsoTotalVisitsRest totalVisitPoint = new UsageReportPointDsoTotalVisitsRest(); totalVisitPoint.setType(StringUtils.substringAfterLast(dso.getClass().getName().toLowerCase(), ".")); totalVisitPoint.setId(dso.getID().toString()); - if (dataset.getColLabels().size() > 0) { + if (!dataset.getColLabels().isEmpty()) { totalVisitPoint.setLabel(dso.getName()); totalVisitPoint.addValue("views", Integer.valueOf(dataset.getMatrix()[0][0])); } else { @@ -205,10 +210,14 @@ private UsageReportRest resolveTotalVisits(Context context, DSpaceObject dso) */ private UsageReportRest resolveTotalVisitsPerMonth(Context context, DSpaceObject dso) throws SQLException, IOException, ParseException, SolrServerException { + String startDateInterval = + configurationService.getProperty("usage-statistics.startDateInterval", "-6"); + String endDateInterval = + configurationService.getProperty("usage-statistics.endDateInterval", "+1"); + StatisticsTable statisticsTable = new StatisticsTable(new StatisticsDataVisits(dso)); DatasetTimeGenerator timeAxis = new DatasetTimeGenerator(); - // TODO month start and end as request para? - timeAxis.setDateInterval("month", "-6", "+1"); + timeAxis.setDateInterval("month", startDateInterval, endDateInterval); statisticsTable.addDatasetGenerator(timeAxis); DatasetDSpaceObjectGenerator dsoAxis = new DatasetDSpaceObjectGenerator(); dsoAxis.addDsoChild(dso.getType(), 10, false, -1); @@ -275,7 +284,10 @@ private UsageReportRest resolveTotalDownloads(Context context, DSpaceObject dso) */ private UsageReportRest resolveTopCountries(Context context, DSpaceObject dso) throws SQLException, IOException, ParseException, SolrServerException { - Dataset dataset = this.getTypeStatsDataset(context, dso, "countryCode", 1); + int topCountriesLimit = + configurationService.getIntProperty("usage-statistics.topCountriesLimit", 100); + + Dataset dataset = this.getTypeStatsDataset(context, dso, "countryCode", topCountriesLimit, 1); UsageReportRest usageReportRest = new UsageReportRest(); for (int i = 0; i < dataset.getColLabels().size(); i++) { @@ -299,7 +311,10 @@ private UsageReportRest resolveTopCountries(Context context, DSpaceObject dso) */ private UsageReportRest resolveTopCities(Context context, DSpaceObject dso) throws SQLException, IOException, ParseException, SolrServerException { - Dataset dataset = this.getTypeStatsDataset(context, dso, "city", 1); + int topCitiesLimit = + configurationService.getIntProperty("usage-statistics.topCitiesLimit", 100); + + Dataset dataset = this.getTypeStatsDataset(context, dso, "city", topCitiesLimit, 1); UsageReportRest usageReportRest = new UsageReportRest(); for (int i = 0; i < dataset.getColLabels().size(); i++) { @@ -339,16 +354,17 @@ private Dataset getDSOStatsDataset(Context context, DSpaceObject dso, int facetM * @param dso DSO we want the stats dataset of * @param typeAxisString String of the type we want on the axis of the dataset (corresponds to solr field), * examples: countryCode, city + * @param typeAxisMax Maximum amount of results to return in the dataset * @param facetMinCount Minimum amount of results on a facet data point for it to be added to dataset * @return Stats dataset with the given type on the axis, of the given DSO and with given facetMinCount */ - private Dataset getTypeStatsDataset(Context context, DSpaceObject dso, String typeAxisString, int facetMinCount) + private Dataset getTypeStatsDataset(Context context, DSpaceObject dso, String typeAxisString, int typeAxisMax, + int facetMinCount) throws SQLException, IOException, ParseException, SolrServerException { StatisticsListing statListing = new StatisticsListing(new StatisticsDataVisits(dso)); DatasetTypeGenerator typeAxis = new DatasetTypeGenerator(); typeAxis.setType(typeAxisString); - // TODO make max nr of top countries/cities a request para? Must be set - typeAxis.setMax(100); + typeAxis.setMax(typeAxisMax); statListing.addDatasetGenerator(typeAxis); return statListing.getDataset(context, facetMinCount); } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/oai/OAIPMHSanitizeIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/oai/OAIPMHSanitizeIT.java new file mode 100644 index 00000000000..32864425c46 --- /dev/null +++ b/dspace-server-webapp/src/test/java/org/dspace/app/oai/OAIPMHSanitizeIT.java @@ -0,0 +1,204 @@ +/** + * 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.oai; + +import static com.lyncode.xoai.dataprovider.core.Granularity.Second; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import javax.xml.parsers.DocumentBuilderFactory; + +import com.lyncode.xoai.dataprovider.xml.XmlOutputContext; +import com.lyncode.xoai.dataprovider.xml.xoai.Metadata; +import org.apache.commons.lang3.StringUtils; +import org.dspace.app.rest.test.AbstractControllerIntegrationTest; +import org.dspace.builder.CollectionBuilder; +import org.dspace.builder.CommunityBuilder; +import org.dspace.builder.ItemBuilder; +import org.dspace.content.Collection; +import org.dspace.content.Community; +import org.dspace.content.Item; +import org.dspace.xoai.util.ItemUtils; +import org.junit.Before; +import org.junit.Test; +import org.xml.sax.InputSource; + +/** + * `ItemUtils.sanitize()` decides what reaches VLO and OLAC, and had no test at all. + * + * Two failure modes matter. Escaping the value before the XOAI writer escapes it again produces + * double-escaped output - a title containing `` reaches a harvester as `&lt;b&gt;`, which is + * what 7.6.6's `escapeXml10` did. Leaving XML-1.0-illegal characters in place makes the StAX writer + * throw inside `XOAI.index()`, which catches per item, so the record is silently dropped from the OAI + * index instead of failing loudly. + * + * The assertions run over the real serialisation path used by `DSpaceXOAIItemCacheService.put()`, and + * re-parse the result rather than only writing it - `U+FFFE` writes without complaint and only blows up + * on the harvester's parser. + * + * @author Milan Majchrak (dspace at dataquest.sk) + */ +public class OAIPMHSanitizeIT extends AbstractControllerIntegrationTest { + + private Collection collection; + + @Before + public void setupStructure() { + context.turnOffAuthorisationSystem(); + Community community = CommunityBuilder.createCommunity(context) + .withName("Sanitize Test Community") + .build(); + collection = CollectionBuilder.createCollection(context, community) + .withName("Sanitize Test Collection") + .build(); + context.restoreAuthSystemState(); + } + + /** + * The regression 7.6.6 introduced: metacharacters must survive one round trip, not two escapes. + */ + @Test + public void metacharactersRoundTripExactlyOnce() throws Exception { + String title = "Corpus of Czech & \"spoken\" 'texts' > 2000"; + + String xml = serialize(buildItem(title)); + + // written form is escaped once - if it were escaped twice this would contain `&lt;` + assertThat(xml, not(org.hamcrest.Matchers.containsString("&lt;"))); + assertThat(xml, not(org.hamcrest.Matchers.containsString("&amp;"))); + // and parsing it back yields the original characters + assertThat(parsedTitle(xml), is(title)); + } + + /** + * Control characters and unpaired surrogates are what actually drop records. + */ + @Test + public void illegalCharactersAreRemovedInsteadOfDroppingTheRecord() throws Exception { + // built from explicit code points so the source file carries no raw control bytes. + // U+0000 is deliberately absent: PostgreSQL cannot store a NUL in a text column, so it never + // reaches the sanitiser and asserting on it would be testing the persistence layer instead. + char backspace = (char) 0x08; + char verticalTab = (char) 0x0B; + char loneHigh = '\uD800'; + char loneLow = '\uDC00'; + char[] illegal = {backspace, verticalTab, loneHigh, loneLow}; + + String title = "corpus with " + backspace + " controls " + verticalTab + + " and " + loneHigh + " a lone high and " + loneLow + " a lone low surrogate"; + + String parsed = parsedTitle(serialize(buildItem(title))); + + assertThat("the record must still carry a title", StringUtils.isNotBlank(parsed), is(true)); + for (char c : illegal) { + assertThat("illegal code unit " + (int) c + " leaked into the OAI output", + parsed.indexOf(c), is(-1)); + } + + // No visible text may be lost. Whitespace is normalised out of the comparison on purpose: + // DSpace replaces C0 controls with a space on the way into the database, so by the time the + // value reaches sanitize() the spacing is already not ours to predict - asserting on it would + // be testing the persistence layer. What is ours is that nothing else disappears. + String expected = title; + for (char c : illegal) { + expected = expected.replace(String.valueOf(c), ""); + } + assertThat(parsed.replaceAll("\\s+", " ").trim(), + is(expected.replaceAll("\\s+", " ").trim())); + } + + /** + * Supplementary characters are legal and must not be mistaken for unpaired surrogates - CLARIN + * metadata carries emoji and non-BMP scripts. `U+1FFFE` is legal XML 1.0, only discouraged, and its + * BMP sibling `U+FFFE` is not - removing the wrong one of the two would be invisible until a + * harvester re-parses the page. + */ + @Test + public void supplementaryCharactersSurviveButNoncharactersInTheBmpDoNot() throws Exception { + // written as surrogate pairs so the source file stays plain ASCII + String emoji = "😀"; // U+1F600 grinning face + String linearB = "𐀀"; // U+10000, the lowest supplementary code point + String legalNoncharacter = "🿾"; // U+1FFFE - legal XML 1.0, merely discouraged + char illegalNoncharacter = (char) 0xFFFE; // U+FFFE - illegal, breaks a re-parse + + String title = "emoji " + emoji + " rare " + linearB + " legal " + legalNoncharacter + + " illegal " + illegalNoncharacter + " end"; + + String parsed = parsedTitle(serialize(buildItem(title))); + + assertTrue("emoji must survive", parsed.contains(emoji)); + assertTrue("valid surrogate pair must survive", parsed.contains(linearB)); + assertTrue("U+1FFFE is legal XML 1.0 and must survive", parsed.contains(legalNoncharacter)); + assertThat("U+FFFE is illegal and would break the harvester's parser", + parsed.indexOf(illegalNoncharacter), is(-1)); + } + + /** + * Whatever we emit has to be re-parseable; writing alone proves nothing. + */ + @Test + public void outputIsAlwaysWellFormed() throws Exception { + String title = "]]> " + (char) 0x0C + " " + (char) 0xFFFF + " \" '"; + + String xml = serialize(buildItem(title)); + + DocumentBuilderFactory.newInstance().newDocumentBuilder() + .parse(new InputSource(new java.io.StringReader(xml))); + } + + private Item buildItem(String title) { + context.turnOffAuthorisationSystem(); + try { + return ItemBuilder.createItem(context, collection).withTitle(title).build(); + } finally { + context.restoreAuthSystemState(); + } + } + + /** + * Exactly what DSpaceXOAIItemCacheService.put() does. + */ + private String serialize(Item item) throws Exception { + Metadata metadata = ItemUtils.retrieveMetadata(context, item); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + XmlOutputContext outputContext = XmlOutputContext.emptyContext(output, Second); + metadata.write(outputContext); + outputContext.getWriter().flush(); + outputContext.getWriter().close(); + return output.toString(StandardCharsets.UTF_8); + } + + /** + * Pull dc.title back out of the serialised document by parsing it, so the assertions are about what + * a harvester sees rather than about our own in-memory objects. + */ + private String parsedTitle(String xml) throws Exception { + org.w3c.dom.Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder() + .parse(new InputSource(new java.io.StringReader(xml))); + // … + org.w3c.dom.NodeList elements = doc.getElementsByTagName("element"); + for (int i = 0; i < elements.getLength(); i++) { + org.w3c.dom.Element element = (org.w3c.dom.Element) elements.item(i); + if (!"title".equals(element.getAttribute("name"))) { + continue; + } + org.w3c.dom.NodeList fields = element.getElementsByTagName("field"); + for (int j = 0; j < fields.getLength(); j++) { + org.w3c.dom.Element field = (org.w3c.dom.Element) fields.item(j); + if ("value".equals(field.getAttribute("name"))) { + return field.getTextContent(); + } + } + } + throw new AssertionError("no dc.title in the serialised XOAI document:\n" + xml); + } +} diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/AuthenticationRestControllerIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/AuthenticationRestControllerIT.java index b23811f27f1..006f6e8d392 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/AuthenticationRestControllerIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/AuthenticationRestControllerIT.java @@ -113,6 +113,10 @@ public class AuthenticationRestControllerIT extends AbstractControllerIntegratio "org.dspace.authenticate.ShibAuthentication", "org.dspace.authenticate.PasswordAuthentication" }; + public static final String[] PASS_AND_SHIB = { + "org.dspace.authenticate.PasswordAuthentication", + "org.dspace.authenticate.ShibAuthentication" + }; public static final String[] SHIB_AND_IP = { "org.dspace.authenticate.IPAuthentication", "org.dspace.authenticate.ShibAuthentication" @@ -1849,6 +1853,101 @@ private boolean tokenClaimsEqual(String token1, String token2) { } } + @Test + public void testShibbolethStaffMappedToStaffAndMembers() throws Exception { + context.turnOffAuthorisationSystem(); + + GroupBuilder.createGroup(context) + .withName("Staff") + .build(); + GroupBuilder.createGroup(context) + .withName("Member") + .build(); + + setAuthenticationMethodSequence(SHIB_ONLY); + configurationService.setProperty("authentication-shibboleth.role.staff", "Staff, Member"); + configurationService.setProperty("authentication-shibboleth.default-roles", "staff"); + configurationService.setProperty("authentication-shibboleth.netid-header", "mail"); + configurationService.setProperty("authentication-shibboleth.email-header", "mail"); + + context.restoreAuthSystemState(); + + String shibToken = getClient().perform(post("/api/authn/login") + .requestAttr("mail", eperson.getEmail()) + .requestAttr("SHIB-SCOPED-AFFILIATION", "staff")) + .andExpect(status().isOk()) + .andReturn().getResponse().getHeader(AUTHORIZATION_HEADER).replace(AUTHORIZATION_TYPE, ""); + + getClient(shibToken).perform(get("/api/authn/status").param("projection", "full")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.okay", is(true))) + .andExpect(jsonPath("$.authenticated", is(true))) + .andExpect(jsonPath("$.authenticationMethod", is("shibboleth"))) + .andExpect(jsonPath("$._embedded.specialGroups._embedded.specialGroups", + Matchers.containsInAnyOrder( + matchGroupWithName("Staff"), + matchGroupWithName("Member") + ) + )); + + getClient(shibToken).perform(get("/api/authn/status/specialGroups").param("projection", "full")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.specialGroups", + Matchers.containsInAnyOrder( + matchGroupWithName("Staff"), + matchGroupWithName("Member") + ) + )); + } + + @Test + public void testPasswordLoginNotMappedToStaffAndMembers() throws Exception { + context.turnOffAuthorisationSystem(); + + GroupBuilder.createGroup(context) + .withName("Staff") + .build(); + GroupBuilder.createGroup(context) + .withName("Member") + .build(); + GroupBuilder.createGroup(context) + .withName("specialGroupPwd") + .build(); + + + setAuthenticationMethodSequence(PASS_AND_SHIB); + configurationService.setProperty("authentication-shibboleth.role.staff", "Staff, Member"); + configurationService.setProperty("authentication-shibboleth.default-roles", "staff"); + configurationService.setProperty("authentication-shibboleth.netid-header", "mail"); + configurationService.setProperty("authentication-shibboleth.email-header", "mail"); + configurationService.setProperty("authentication-password.login.specialgroup", "specialGroupPwd"); + + context.restoreAuthSystemState(); + + String passwordToken = getAuthToken(eperson.getEmail(), password); + + getClient(passwordToken).perform(get("/api/authn/status").param("projection", "full")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.okay", is(true))) + .andExpect(jsonPath("$.authenticated", is(true))) + .andExpect(jsonPath("$.authenticationMethod", is("password"))) + .andExpect(jsonPath("$._embedded.specialGroups._embedded.specialGroups", + Matchers.containsInAnyOrder( + matchGroupWithName("specialGroupPwd") + ) + )); + + getClient(passwordToken).perform(get("/api/authn/status/specialGroups").param("projection", "full")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.specialGroups", + Matchers.containsInAnyOrder( + matchGroupWithName("specialGroupPwd") + ) + )); + } + + + private OrcidTokenResponseDTO buildOrcidTokenResponse(String orcid, String accessToken) { OrcidTokenResponseDTO token = new OrcidTokenResponseDTO(); token.setAccessToken(accessToken); diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestControllerIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestControllerIT.java index e0c64a71d07..1cf2b245918 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestControllerIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestControllerIT.java @@ -8,7 +8,6 @@ package org.dspace.app.rest; import static java.util.UUID.randomUUID; -import static javax.mail.internet.MimeUtility.encodeText; import static org.apache.commons.codec.CharEncoding.UTF_8; import static org.apache.commons.collections.CollectionUtils.isEmpty; import static org.apache.commons.io.IOUtils.toInputStream; @@ -364,7 +363,11 @@ public void testBitstreamName() throws Exception { //2. A public item with a bitstream String bitstreamContent = "0123456789"; - String bitstreamName = "ภาษาไทย"; + String bitstreamName = "ภาษาไทย-com-acentuação.pdf"; + String expectedAscii = "-com-acentuacao.pdf"; + String expectedUtf8Encoded = + "%E0%B8%A0%E0%B8%B2%E0%B8%A9%E0%B8%B2%E0%B9%84%E0%B8%97%E0%B8%A2-" + + "com-acentua%C3%A7%C3%A3o.pdf"; try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { @@ -388,7 +391,9 @@ public void testBitstreamName() throws Exception { //We expect the content disposition to have the encoded bitstream name .andExpect(header().string( "Content-Disposition", - "attachment;filename=\"" + encodeText(bitstreamName) + "\"" + String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s", + expectedAscii, + expectedUtf8Encoded) )); } @@ -1271,12 +1276,8 @@ public void closeInputStreamsDownloadWithCoverPage() throws Exception { @Test public void checkContentDispositionOfFormats() throws Exception { - configurationService.setProperty("webui.content_disposition_format", new String[] { - "text/richtext", - "text/xml", - "txt" - }); - + // This test verifies that, by default, common text formats will be downloaded instead of being served inline. + // The next two tests will verify behavior of non-default settings of "webui.content_disposition_inline" context.turnOffAuthorisationSystem(); Community community = CommunityBuilder.createCommunity(context).build(); Collection collection = CollectionBuilder.createCollection(context, community).build(); @@ -1298,18 +1299,26 @@ public void checkContentDispositionOfFormats() throws Exception { } context.restoreAuthSystemState(); - // these formats are configured and files should be downloaded + // Based on default configuration all files should be downloaded verifyBitstreamDownload(rtf, "text/richtext;charset=UTF-8", true); verifyBitstreamDownload(xml, "text/xml;charset=UTF-8", true); verifyBitstreamDownload(txt, "text/plain;charset=UTF-8", true); - // this format is not configured and should open inline - verifyBitstreamDownload(csv, "text/csv;charset=UTF-8", false); + verifyBitstreamDownload(csv, "text/csv;charset=UTF-8", true); } @Test - public void checkHardcodedContentDispositionFormats() throws Exception { - // This test is similar to the above test, but it verifies that our *hardcoded settings* for - // webui.content_disposition_format are protecting us from loading specific formats *inline*. + public void checkBannedContentDispositionInlineFormats() throws Exception { + configurationService.setProperty("webui.content_disposition_inline", new String[] { + "text/html", + "text/javascript", + "rdf", + "text/xml", + "image/svg+xml" + }); + + // This test is similar to the above test, but it verifies that if a site specifies + // a banned format (e.g. HTML, XML, etc) in their "webui.content_disposition_inline" setting + // DSpace will still protect them by refusing to load the format *inline*. context.turnOffAuthorisationSystem(); Community community = CommunityBuilder.createCommunity(context).build(); Collection collection = CollectionBuilder.createCollection(context, community).build(); @@ -1339,8 +1348,9 @@ public void checkHardcodedContentDispositionFormats() throws Exception { } context.restoreAuthSystemState(); - // By default, HTML, JS & XML should all download. This protects us from possible XSS attacks, as - // each of these formats can embed JavaScript which may execute when the file is loaded *inline*. + // By default, HTML, JS & XML should all download regardless of inline configuration. + // This protects us from possible XSS attacks, as each of these formats can embed JavaScript + // which may execute when the file is loaded *inline*. verifyBitstreamDownload(html, "text/html;charset=UTF-8", true); verifyBitstreamDownload(js, "text/javascript;charset=UTF-8", true); verifyBitstreamDownload(rdf, "application/rdf+xml;charset=UTF-8", true); @@ -1353,22 +1363,29 @@ public void checkHardcodedContentDispositionFormats() throws Exception { } @Test - public void checkWildcardContentDispositionFormats() throws Exception { - // Setting "*" should result in all formats being downloaded (nothing will be opened inline) - configurationService.setProperty("webui.content_disposition_format", "*"); - + public void checkContentDispositionInlineFormats() throws Exception { + // Set PDF and a few image formats to verify they will display inline. But leave off "text/plain" + configurationService.setProperty("webui.content_disposition_inline", new String[] { + "text/csv", + "application/pdf", + "image/jpeg", + "video/mpeg" + }); context.turnOffAuthorisationSystem(); Community community = CommunityBuilder.createCommunity(context).build(); Collection collection = CollectionBuilder.createCollection(context, community).build(); Item item = ItemBuilder.createItem(context, collection).build(); String content = "Test Content"; Bitstream csv; + Bitstream txt; Bitstream jpg; Bitstream mpg; Bitstream pdf; try (InputStream is = IOUtils.toInputStream(content, CharEncoding.UTF_8)) { csv = BitstreamBuilder.createBitstream(context, item, is) .withMimeType("text/csv").build(); + txt = BitstreamBuilder.createBitstream(context, item, is) + .withMimeType("text/plain").build(); jpg = BitstreamBuilder.createBitstream(context, item, is) .withMimeType("image/jpeg").build(); mpg = BitstreamBuilder.createBitstream(context, item, is) @@ -1378,11 +1395,13 @@ public void checkWildcardContentDispositionFormats() throws Exception { } context.restoreAuthSystemState(); - // All formats should be download only - verifyBitstreamDownload(csv, "text/csv;charset=UTF-8", true); - verifyBitstreamDownload(jpg, "image/jpeg;charset=UTF-8", true); - verifyBitstreamDownload(mpg, "video/mpeg;charset=UTF-8", true); - verifyBitstreamDownload(pdf, "application/pdf;charset=UTF-8", true); + // Only text/plain should download, while other formats should be served inline based on the configuration + verifyBitstreamDownload(csv, "text/csv;charset=UTF-8", false); + verifyBitstreamDownload(jpg, "image/jpeg;charset=UTF-8", false); + verifyBitstreamDownload(mpg, "video/mpeg;charset=UTF-8", false); + verifyBitstreamDownload(pdf, "application/pdf;charset=UTF-8", false); + // This is the only format not listed in the inline configuration, so it will be downloaded + verifyBitstreamDownload(txt, "text/plain;charset=UTF-8", true); } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/BrowsesResourceControllerIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/BrowsesResourceControllerIT.java index 9d875669c4a..7ef04325188 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/BrowsesResourceControllerIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/BrowsesResourceControllerIT.java @@ -1887,8 +1887,8 @@ public void testBrowseByItemsStartsWith() throws Exception { // ---- BROWSES BY ITEM ---- //** WHEN ** //An anonymous user browses the items in the Browse by date issued endpoint - //with startsWith set to 199 - getClient().perform(get("/api/discover/browses/dateissued/items?startsWith=199") + //with startsWith set to 1990 + getClient().perform(get("/api/discover/browses/dateissued/items?startsWith=1990") .param("size", "2")) //** THEN ** @@ -1897,8 +1897,8 @@ public void testBrowseByItemsStartsWith() throws Exception { //We expect the content type to be "application/hal+json;charset=UTF-8" .andExpect(content().contentType(contentType)) - //We expect the totalElements to be the 2 items present in the repository - .andExpect(jsonPath("$.page.totalElements", is(2))) + //We expect the totalElements to be the 5 items from 1990 til now + .andExpect(jsonPath("$.page.totalElements", is(5))) //We expect to jump to page 1 of the index .andExpect(jsonPath("$.page.number", is(0))) .andExpect(jsonPath("$.page.size", is(2))) @@ -2059,8 +2059,8 @@ public void testBrowseByStartsWithAndPage() throws Exception { //** WHEN ** //An anonymous user browses the items in the Browse by date issued endpoint - //with startsWith set to 199 and Page to 1 - getClient().perform(get("/api/discover/browses/dateissued/items?startsWith=199") + //with startsWith set to 1990 and Page to 1 + getClient().perform(get("/api/discover/browses/dateissued/items?startsWith=1990") .param("size", "1").param("page", "1")) //** THEN ** @@ -2069,17 +2069,76 @@ public void testBrowseByStartsWithAndPage() throws Exception { //We expect the content type to be "application/hal+json;charset=UTF-8" .andExpect(content().contentType(contentType)) - //We expect the totalElements to be the 2 items present in the repository - .andExpect(jsonPath("$.page.totalElements", is(2))) + //We expect the totalElements to be the 5 items present in the repository from 1990 until now + .andExpect(jsonPath("$.page.totalElements", is(5))) //We expect to jump to page 1 of the index .andExpect(jsonPath("$.page.number", is(1))) .andExpect(jsonPath("$.page.size", is(1))) - .andExpect(jsonPath("$._links.self.href", containsString("startsWith=199"))) + .andExpect(jsonPath("$._links.self.href", containsString("startsWith=1990"))) - //Verify that the index jumps to the "Java" item. - .andExpect(jsonPath("$._embedded.items", - contains( - ItemMatcher.matchItemWithTitleAndDateIssued(item3, "Java", "1995-05-23") + //Verify that the returned item is 2nd (page 0 first item, page 1 second item) item from 1990 + // Items: Alan Turing - 1912; Blade Runner - 1982-06-25 || Python - 1990; + // Java - 1995-05-23; Zeta Reticuli - 2018-01-01; Moon - 2018-01-02; T-800 - 2029 + // 2nd since 1990: Java + .andExpect(jsonPath("$._embedded.items", + contains(ItemMatcher.matchItemWithTitleAndDateIssued(item3, + "Java", "1995-05-23") + ))); + + getClient().perform(get("/api/discover/browses/dateissued/items?startsWith=1990") + .param("size", "2").param("page", "1")) + //Verify that the returned item is 3rd&4th item from 1990 + // Items: Alan Turing - 1912; Blade Runner - 1982-06-25 || Python - 1990; + // Java - 1995-05-23; Zeta Reticuli - 2018-01-01; Moon - 2018-01-02; T-800 - 2029 + // => Zeta Reticuli & Moon + .andExpect(jsonPath("$._embedded.items", + contains(ItemMatcher.matchItemWithTitleAndDateIssued(item7, + "Zeta Reticuli", "2018-01-01"), + ItemMatcher.matchItemWithTitleAndDateIssued(item4, + "Moon", "2018-01-02") + ))); + + // Sort descending + getClient().perform(get("/api/discover/browses/dateissued/items?startsWith=1990&sort=default,DESC") + .param("size", "2").param("page", "0")) + //Verify that the returned items are from 1990 and below dates + // Items: Alan Turing - 1912; Blade Runner - 1982-06-25 || Python - 1990; + // Java - 1995-05-23; Zeta Reticuli - 2018-01-01; Moon - 2018-01-02; T-800 - 2029 + // => Python & Blade Runner + .andExpect(jsonPath("$._embedded.items", + contains(ItemMatcher.matchItemWithTitleAndDateIssued(item5, + "Python", "1990"), + ItemMatcher.matchItemWithTitleAndDateIssued(item2, + "Blade Runner", "1982-06-25") + ))); + + getClient().perform(get("/api/discover/browses/dateissued/items?startsWith=1990&sort=default,DESC") + .param("size", "1").param("page", "0")) + //Verify that the returned item is the one closest to 1990 but below its upperBound (1990-12-31) + .andExpect(jsonPath("$._embedded.items", + contains(ItemMatcher.matchItemWithTitleAndDateIssued(item5, + "Python", "1990") + ))); + + getClient().perform(get("/api/discover/browses/dateissued/items?startsWith=1990&sort=default,DESC") + .param("size", "3").param("page", "0")) + //Verify that the 3 returned items are from 1990 and below dates, + // with closest to upperBound 1990-12-31 as first + .andExpect(jsonPath("$._embedded.items", + contains(ItemMatcher.matchItemWithTitleAndDateIssued(item5, + "Python", "1990"), + ItemMatcher.matchItemWithTitleAndDateIssued(item2, + "Blade Runner", "1982-06-25"), + ItemMatcher.matchItemWithTitleAndDateIssued(item1, + "Alan Turing", "1912-06-23") + ))); + + getClient().perform(get("/api/discover/browses/dateissued/items?startsWith=1982-06&sort=default,DESC") + .param("size", "1").param("page", "0")) + //Verify that the returned item is the one closest to 1982-06 but below its upperBound (1982-06-30) + .andExpect(jsonPath("$._embedded.items", + contains(ItemMatcher.matchItemWithTitleAndDateIssued(item2, + "Blade Runner", "1982-06-25") ))); } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/CollectionRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/CollectionRestRepositoryIT.java index 5ad83390144..e3e074e8ec2 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/CollectionRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/CollectionRestRepositoryIT.java @@ -56,6 +56,7 @@ import org.dspace.app.rest.projection.Projection; import org.dspace.app.rest.test.AbstractControllerIntegrationTest; import org.dspace.app.rest.test.MetadataPatchSuite; +import org.dspace.authorize.ResourcePolicy; import org.dspace.authorize.service.AuthorizeService; import org.dspace.authorize.service.ResourcePolicyService; import org.dspace.builder.CollectionBuilder; @@ -3364,6 +3365,109 @@ public void addColAdminGroupToCheckReindexingTest() throws Exception { .andExpect(jsonPath("$.page.totalElements", is(1))); } + @Test + public void addParentComAdminGroupToCheckAdminPropagationTest() throws Exception { + addParentComAdminGroupToCheckGenericPropagationTest("findAdminAuthorized"); + } + + @Test + public void addParentComAdminGroupToCheckEditPropagationTest() throws Exception { + addParentComAdminGroupToCheckGenericPropagationTest("findEditAuthorized"); + } + + public void addParentComAdminGroupToCheckGenericPropagationTest(String method) throws Exception { + context.turnOffAuthorisationSystem(); + + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity) + .withName("MyTest") + .build(); + + context.restoreAuthSystemState(); + + String epersonToken = getAuthToken(eperson.getEmail(), password); + getClient(epersonToken).perform(get("/api/core/collections/search/" + method) + .param("query", "MyTest")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded").doesNotExist()) + .andExpect(jsonPath("$.page.totalElements", is(0))); + + AtomicReference idRef = new AtomicReference<>(); + ObjectMapper mapper = new ObjectMapper(); + GroupRest groupRest = new GroupRest(); + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(post("/api/core/communities/" + parentCommunity.getID() + "/adminGroup") + .content(mapper.writeValueAsBytes(groupRest)) + .contentType(contentType)) + .andExpect(status().isCreated()) + .andDo(result -> idRef.set( + UUID.fromString(read(result.getResponse().getContentAsString(), "$.id"))) + ); + + String adminToken = getAuthToken(admin.getEmail(), password); + getClient(adminToken).perform(post("/api/eperson/groups/" + idRef.get() + "/epersons") + .contentType(parseMediaType(TEXT_URI_LIST_VALUE)) + .content(REST_SERVER_URL + "eperson/groups/" + eperson.getID() + )); + + getClient(epersonToken).perform(get("/api/core/collections/search/" + method) + .param("query", "MyTest")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.collections", Matchers.contains(CollectionMatcher + .matchProperties(col1.getName(), col1.getID(), col1.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + } + + @Test + public void removeParentComAdminPolicyToCheckAdminPropagationTest() throws Exception { + removeParentComAdminPolicyToCheckGenericPropagationTest("findAdminAuthorized"); + } + + @Test + public void removeParentComAdminPolicyToCheckEditPropagationTest() throws Exception { + removeParentComAdminPolicyToCheckGenericPropagationTest("findEditAuthorized"); + } + + public void removeParentComAdminPolicyToCheckGenericPropagationTest(String method) throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + + ResourcePolicy policy = ResourcePolicyBuilder.createResourcePolicy(context, eperson, null) + .withDspaceObject(parentCommunity).withAction(Constants.ADMIN) + .build(); + + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity) + .withName("MyTest") + .build(); + + context.restoreAuthSystemState(); + + String epersonToken = getAuthToken(eperson.getEmail(), password); + getClient(epersonToken).perform(get("/api/core/collections/search/" + method) + .param("query", "MyTest")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.collections", Matchers.contains(CollectionMatcher + .matchProperties(col1.getName(), col1.getID(), col1.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(delete("/api/authz/resourcepolicies/" + policy.getID())) + .andExpect(status().is(204)); + + getClient(epersonToken).perform(get("/api/core/collections/search/" + method) + .param("query", "MyTest")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded").doesNotExist()) + .andExpect(jsonPath("$.page.totalElements", is(0))); + } + @Test public void findAuthorizedCollectionsByEntityType() throws Exception { context.turnOffAuthorisationSystem(); @@ -3702,4 +3806,296 @@ public void findSubmitAuthorizedByCommunityAndEntityTypeNotFoundTest() throws Ex .andExpect(status().isNotFound()); } + @Test + public void findEditAuthorizedUnauthorizedTest() throws Exception { + getClient().perform(get("/api/core/collections/search/findEditAuthorized")) + .andExpect(status().isUnauthorized()); + } + + @Test + public void findEditAuthorizedResourcePolicyTest() throws Exception { + context.turnOffAuthorisationSystem(); + Community comm1 = CommunityBuilder.createCommunity(context).withName("Community 1").build(); + + EPerson hasDirectEditRights = EPersonBuilder.createEPerson(context) + .withEmail("has@editrights.com").withPassword(password) + .build(); + EPerson hasDirectAdminRights = EPersonBuilder.createEPerson(context) + .withEmail("has@adminrights.com").withPassword(password) + .build(); + Collection byResourcePolicy = CollectionBuilder.createCollection(context, comm1) + .withName("direct edit rights for eperson") + .build(); + Collection uneditable = CollectionBuilder.createCollection(context, comm1) + .withName("uneditable collection") + .build(); + ResourcePolicy policy = ResourcePolicyBuilder.createResourcePolicy(context, hasDirectEditRights, null) + .withDspaceObject(byResourcePolicy).withAction(WRITE) + .build(); + policy = ResourcePolicyBuilder.createResourcePolicy(context, hasDirectAdminRights, null) + .withDspaceObject(byResourcePolicy).withAction(Constants.ADMIN) + .build(); + context.restoreAuthSystemState(); + + String tokenHasDirectEditRightsToken = getAuthToken(hasDirectEditRights.getEmail(), password); + String tokenHasDirectAdminRightsToken = getAuthToken(hasDirectAdminRights.getEmail(), password); + + getClient(tokenHasDirectEditRightsToken).perform(get("/api/core/collections/search/findEditAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.collections", + Matchers.contains(CollectionMatcher.matchCollection(byResourcePolicy)))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + getClient(tokenHasDirectAdminRightsToken).perform(get("/api/core/collections/search/findEditAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.collections", + Matchers.contains(CollectionMatcher.matchCollection(byResourcePolicy)))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + } + + @Test + public void findEditAuthorizedAdminPropagationTest() throws Exception { + + context.turnOffAuthorisationSystem(); + + /* + DSO structure: + root + ├── subcomm1 + ├── subcomm1collA (collection) + └── subcomm2subcomm3 (community) + ├── subcomm2subcomm3collB (collection) + └── subcomm2 + └── subcomm2coll + */ + EPerson rootAdmin = EPersonBuilder.createEPerson(context) + .withEmail("root@admin.com").withPassword(password).build(); + EPerson subcomm1Admin = EPersonBuilder.createEPerson(context) + .withEmail("subcomm1@admin.com").withPassword(password).build(); + EPerson subcomm2Admin = EPersonBuilder.createEPerson(context) + .withEmail("subcomm2@admin.com").withPassword(password).build(); + EPerson subcomm1collA_Admin = EPersonBuilder.createEPerson(context) + .withEmail("subcomm1collA@admin.com").withPassword(password).build(); + EPerson subcomm1collB_Admin = EPersonBuilder.createEPerson(context) + .withEmail("subcomm1collB@admin.com").withPassword(password).build(); + EPerson subcomm2collAdmin = EPersonBuilder.createEPerson(context) + .withEmail("subcomm2coll@admin.com").withPassword(password).build(); + + Community root = CommunityBuilder.createCommunity(context) + .withAdminGroup(rootAdmin) + .withName("root") + .build(); + Community subcomm1 = CommunityBuilder.createSubCommunity(context, root) + .withAdminGroup(subcomm1Admin) + .withName("subcomm1") + .build(); + Community subcomm1subcom3 = CommunityBuilder.createSubCommunity(context, subcomm1) + .withName("subcomm1subcom3") + .build(); + Community subcomm2 = CommunityBuilder.createSubCommunity(context, root) + .withAdminGroup(subcomm2Admin) + .withName("subcomm2") + .build(); + Collection subcomm1collA = CollectionBuilder.createCollection(context, subcomm1) + .withAdminGroup(subcomm1collA_Admin) + .withName("subcomm1collA") + .build(); + Collection subcomm2subcomm3collB = CollectionBuilder.createCollection(context, subcomm1subcom3) + .withAdminGroup(subcomm1collB_Admin) + .withName("subcomm2subcomm3collB") + .build(); + Collection subcomm2coll = CollectionBuilder.createCollection(context, subcomm2) + .withAdminGroup(subcomm2collAdmin) + .withName("subcomm2coll") + .build(); + context.restoreAuthSystemState(); + + String siteAdminToken = getAuthToken(admin.getEmail(), password); + String rootAdminToken = getAuthToken(rootAdmin.getEmail(), password); + String subcomm1AdminToken = getAuthToken(subcomm1Admin.getEmail(), password); + String subcomm2AdminToken = getAuthToken(subcomm2Admin.getEmail(), password); + + getClient(siteAdminToken).perform(get("/api/core/collections/search/findEditAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.collections", + Matchers.containsInAnyOrder( + CollectionMatcher.matchCollection(subcomm1collA), + CollectionMatcher.matchCollection(subcomm2subcomm3collB), + CollectionMatcher.matchCollection(subcomm2coll) + ))) + .andExpect(jsonPath("$.page.totalElements", is(3))); + + getClient(rootAdminToken).perform(get("/api/core/collections/search/findEditAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.collections", + Matchers.containsInAnyOrder( + CollectionMatcher.matchCollection(subcomm1collA), + CollectionMatcher.matchCollection(subcomm2subcomm3collB), + CollectionMatcher.matchCollection(subcomm2coll) + ))) + .andExpect(jsonPath("$.page.totalElements", is(3))); + + getClient(subcomm1AdminToken).perform(get("/api/core/collections/search/findEditAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.collections", + Matchers.containsInAnyOrder( + CollectionMatcher.matchCollection(subcomm1collA), + CollectionMatcher.matchCollection(subcomm2subcomm3collB) + ))) + .andExpect(jsonPath("$.page.totalElements", is(2))); + + getClient(subcomm2AdminToken).perform(get("/api/core/collections/search/findEditAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.collections", + Matchers.containsInAnyOrder( + CollectionMatcher.matchCollection(subcomm2coll) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + } + + @Test + public void findEditAuthorizedCollectionsWithQueryTest() throws Exception { + findGenericAuthorizedCollectionsWithQueryTest("findEditAuthorized"); + } + + @Test + public void findReadAuthorizedCollectionsWithQueryTest() throws Exception { + findGenericAuthorizedCollectionsWithQueryTest("findAdminAuthorized"); + } + + public void findGenericAuthorizedCollectionsWithQueryTest(String method) throws Exception { + + context.turnOffAuthorisationSystem(); + + EPerson eperson2 = EPersonBuilder.createEPerson(context) + .withEmail("eperson2@mail.com") + .withPassword(password) + .build(); + + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Community child1 = CommunityBuilder.createSubCommunity(context, parentCommunity) + .withName("Sub Community") + .build(); + Community child2 = CommunityBuilder.createSubCommunity(context, parentCommunity) + .withName("Sub Community Two") + .build(); + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Sample collection") + .withAdminGroup(eperson) + .build(); + Collection col2 = CollectionBuilder.createCollection(context, child1) + .withName("Test collection") + .build(); + Collection col3 = CollectionBuilder.createCollection(context, child2) + .withName("Collection of sample items") + .withAdminGroup(eperson) + .build(); + Collection col4 = CollectionBuilder.createCollection(context, child2) + .withName("Testing autocomplete in collection") + .withAdminGroup(eperson2) + .build(); + Collection col5 = CollectionBuilder.createCollection(context, child2) + .withName("Title: subtitle (special characters)") + .build(); + context.restoreAuthSystemState(); + + String tokenEPerson = getAuthToken(eperson.getEmail(), password); + // Test simple query matches + getClient(tokenEPerson).perform(get("/api/core/collections/search/" + method) + .param("query", "collection")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.collections", Matchers.containsInAnyOrder( + CollectionMatcher.matchProperties(col1.getName(), col1.getID(), col1.getHandle()), + CollectionMatcher.matchProperties(col3.getName(), col3.getID(), col3.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(2))); + + // Test insensitive matches + getClient(tokenEPerson).perform(get("/api/core/collections/search/" + method) + .param("query", "COLLECTION")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.collections", Matchers.containsInAnyOrder( + CollectionMatcher.matchProperties(col1.getName(), col1.getID(), col1.getHandle()), + CollectionMatcher.matchProperties(col3.getName(), col3.getID(), col3.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(2))); + + // Test collection unathorized for eperson is not returned + getClient(tokenEPerson).perform(get("/api/core/collections/search/" + method) + .param("query", "test")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page.totalElements", is(0))); + + // Test eperson with no authorized collections + getClient(tokenEPerson).perform(get("/api/core/collections/search/" + method) + .param("query", "auto")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page.totalElements", is(0))); + + String tokenEPerson2 = getAuthToken(eperson2.getEmail(), password); + // Test eperson2 gets only their authorized collection + getClient(tokenEPerson2).perform(get("/api/core/collections/search/" + method) + .param("query", "auto")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.collections", Matchers.contains( + CollectionMatcher.matchProperties(col4.getName(), col4.getID(), col4.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + + // Test query with multiple words + getClient(tokenEPerson2).perform(get("/api/core/collections/search/" + method) + .param("query", "testing auto")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.collections", Matchers.containsInAnyOrder( + CollectionMatcher.matchProperties(col4.getName(), col4.getID(), col4.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + + // Test admin gets all authorized collections + String tokenAdmin = getAuthToken(admin.getEmail(), password); + getClient(tokenAdmin).perform(get("/api/core/collections/search/" + method) + .param("query", "sample")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.collections", Matchers.containsInAnyOrder( + CollectionMatcher.matchProperties(col1.getName(), col1.getID(), col1.getHandle()), + CollectionMatcher.matchProperties(col3.getName(), col3.getID(), col3.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(2))); + + // Test query with unsorted query words + getClient(tokenAdmin).perform(get("/api/core/collections/search/" + method) + .param("query", "items sample")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.collections", Matchers.contains( + CollectionMatcher.matchProperties(col3.getName(), col3.getID(), col3.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + + // Test collection not authorized for eperson is returned for admin + getClient(tokenAdmin).perform(get("/api/core/collections/search/" + method) + .param("query", "test")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.collections", Matchers.containsInAnyOrder( + CollectionMatcher.matchProperties(col2.getName(), col2.getID(), col2.getHandle()), + CollectionMatcher.matchProperties(col4.getName(), col4.getID(), col4.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(2))); + + // Test query with special characters + getClient(tokenAdmin).perform(get("/api/core/collections/search/" + method) + .param("query", "title: subtitle (special")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.collections", Matchers.contains( + CollectionMatcher.matchProperties(col5.getName(), col5.getID(), col5.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + } + } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/CommunityRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/CommunityRestRepositoryIT.java index 0ae32b24361..6408f5e76da 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/CommunityRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/CommunityRestRepositoryIT.java @@ -50,6 +50,7 @@ import org.dspace.app.rest.projection.Projection; import org.dspace.app.rest.test.AbstractControllerIntegrationTest; import org.dspace.app.rest.test.MetadataPatchSuite; +import org.dspace.authorize.ResourcePolicy; import org.dspace.authorize.service.AuthorizeService; import org.dspace.authorize.service.ResourcePolicyService; import org.dspace.builder.CollectionBuilder; @@ -2820,4 +2821,519 @@ public void addComAdminGroupToCheckReindexingTest() throws Exception { .andExpect(jsonPath("$.page.totalElements", is(1))); } + @Test + public void addParentComAdminGroupToCheckAdminPropagationTest() throws Exception { + addParentComAdminGroupToCheckGenericPropagationTest("findAdminAuthorized"); + } + + @Test + public void addParentComAdminGroupToCheckEditPropagationTest() throws Exception { + addParentComAdminGroupToCheckGenericPropagationTest("findEditAuthorized"); + } + + @Test + public void addParentComAdminGroupToCheckAddPropagationTest() throws Exception { + addParentComAdminGroupToCheckGenericPropagationTest("findAddAuthorized"); + } + + public void addParentComAdminGroupToCheckGenericPropagationTest(String method) throws Exception { + context.turnOffAuthorisationSystem(); + + Community rootCommunity = CommunityBuilder.createCommunity(context) + .withName("Root Community") + .build(); + + Community subCommunity = CommunityBuilder.createSubCommunity(context, rootCommunity) + .withName("MyTestCom") + .build(); + + context.restoreAuthSystemState(); + + String epersonToken = getAuthToken(eperson.getEmail(), password); + getClient(epersonToken).perform(get("/api/core/communities/search/" + method) + .param("query", "MyTestCom")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded").doesNotExist()) + .andExpect(jsonPath("$.page.totalElements", is(0))); + + AtomicReference idRef = new AtomicReference<>(); + ObjectMapper mapper = new ObjectMapper(); + GroupRest groupRest = new GroupRest(); + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(post("/api/core/communities/" + rootCommunity.getID() + "/adminGroup") + .content(mapper.writeValueAsBytes(groupRest)) + .contentType(contentType)) + .andExpect(status().isCreated()) + .andDo(result -> idRef.set( + UUID.fromString(read(result.getResponse().getContentAsString(), "$.id"))) + ); + + String adminToken = getAuthToken(admin.getEmail(), password); + getClient(adminToken).perform(post("/api/eperson/groups/" + idRef.get() + "/epersons") + .contentType(parseMediaType(TEXT_URI_LIST_VALUE)) + .content(REST_SERVER_URL + "eperson/groups/" + eperson.getID() + )); + + getClient(epersonToken).perform(get("/api/core/communities/search/" + method) + .param("query", "MyTestCom")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.communities", Matchers.contains(CommunityMatcher + .matchProperties(subCommunity.getName(), + subCommunity.getID(), + subCommunity.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + } + + @Test + public void removeParentComAdminPolicyToCheckAdminPropagationTest() throws Exception { + removeParentComAdminPolicyToCheckGenericPropagationTest("findAdminAuthorized"); + } + + @Test + public void removeParentComAdminPolicyToCheckEditPropagationTest() throws Exception { + removeParentComAdminPolicyToCheckGenericPropagationTest("findEditAuthorized"); + } + + @Test + public void removeParentComAdminPolicyToCheckAddPropagationTest() throws Exception { + removeParentComAdminPolicyToCheckGenericPropagationTest("findAddAuthorized"); + } + + public void removeParentComAdminPolicyToCheckGenericPropagationTest(String method) throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Root Community") + .build(); + + ResourcePolicy policy = ResourcePolicyBuilder.createResourcePolicy(context, eperson, null) + .withDspaceObject(parentCommunity).withAction(Constants.ADMIN) + .build(); + + Community subCommunity = CommunityBuilder.createSubCommunity(context, parentCommunity) + .withName("MyTestCom") + .build(); + context.restoreAuthSystemState(); + + String epersonToken = getAuthToken(eperson.getEmail(), password); + getClient(epersonToken).perform(get("/api/core/communities/search/" + method) + .param("query", "MyTestCom")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.communities", Matchers.contains(CommunityMatcher + .matchProperties(subCommunity.getName(), + subCommunity.getID(), + subCommunity.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(delete("/api/authz/resourcepolicies/" + policy.getID())) + .andExpect(status().is(204)); + + getClient(epersonToken).perform(get("/api/core/communities/search/" + method) + .param("query", "MyTestCom")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded").doesNotExist()) + .andExpect(jsonPath("$.page.totalElements", is(0))); + } + + @Test + public void findEditAuthorizedUnauthorizedTest() throws Exception { + getClient().perform(get("/api/core/communities/search/findEditAuthorized")) + .andExpect(status().isUnauthorized()); + } + + @Test + public void findEditAuthorizedResourcePolicyTest() throws Exception { + context.turnOffAuthorisationSystem(); + Community comm1 = CommunityBuilder.createCommunity(context).withName("Community 1").build(); + + EPerson hasDirectEditRights = EPersonBuilder.createEPerson(context) + .withEmail("has@editrights.com").withPassword(password) + .build(); + EPerson hasDirectAdminRights = EPersonBuilder.createEPerson(context) + .withEmail("has@adminrights.com").withPassword(password) + .build(); + Community byResourcePolicy = CommunityBuilder.createCommunity(context) + .withName("direct edit rights for eperson").build(); + Community uneditable = CommunityBuilder.createCommunity(context) + .withName("uneditable community") + .build(); + ResourcePolicy policy = ResourcePolicyBuilder.createResourcePolicy(context, hasDirectEditRights, null) + .withDspaceObject(byResourcePolicy).withAction(Constants.WRITE) + .build(); + policy = ResourcePolicyBuilder.createResourcePolicy(context, hasDirectAdminRights, null) + .withDspaceObject(byResourcePolicy).withAction(Constants.ADMIN) + .build(); + context.restoreAuthSystemState(); + + String tokenHasDirectEditRightsToken = getAuthToken(hasDirectEditRights.getEmail(), password); + String tokenHasDirectAdminRightsToken = getAuthToken(hasDirectAdminRights.getEmail(), password); + + getClient(tokenHasDirectEditRightsToken).perform(get("/api/core/communities/search/findEditAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.communities", + Matchers.contains(CommunityMatcher.matchCommunity(byResourcePolicy)))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + getClient(tokenHasDirectAdminRightsToken).perform(get("/api/core/communities/search/findEditAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.communities", + Matchers.contains(CommunityMatcher.matchCommunity(byResourcePolicy)))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + } + + @Test + public void findEditAuthorizedAdminPropagationTest() throws Exception { + + context.turnOffAuthorisationSystem(); + + /* + DSO structure: + root + └── subcomm1 + └── subcomm1subcomm2 + */ + EPerson rootAdmin = EPersonBuilder.createEPerson(context) + .withEmail("root@admin.com").withPassword(password).build(); + EPerson subcomm1Admin = EPersonBuilder.createEPerson(context) + .withEmail("subcomm1@admin.com").withPassword(password).build(); + EPerson subcomm2Admin = EPersonBuilder.createEPerson(context) + .withEmail("subcomm2@admin.com").withPassword(password).build(); + + Community root = CommunityBuilder.createCommunity(context) + .withAdminGroup(rootAdmin) + .withName("root") + .build(); + Community subcomm1 = CommunityBuilder.createSubCommunity(context, root) + .withAdminGroup(subcomm1Admin) + .withName("subcomm1") + .build(); + Community subcomm1subcomm2 = CommunityBuilder.createSubCommunity(context, subcomm1) + .withAdminGroup(subcomm2Admin) + .withName("subcomm1subcomm2") + .build(); + context.restoreAuthSystemState(); + + String siteAdminToken = getAuthToken(admin.getEmail(), password); + String rootAdminToken = getAuthToken(rootAdmin.getEmail(), password); + String subcomm1AdminToken = getAuthToken(subcomm1Admin.getEmail(), password); + String subcomm2AdminToken = getAuthToken(subcomm2Admin.getEmail(), password); + + getClient(siteAdminToken).perform(get("/api/core/communities/search/findEditAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.communities", + Matchers.containsInAnyOrder( + CommunityMatcher.matchCommunity(root), + CommunityMatcher.matchCommunity(subcomm1), + CommunityMatcher.matchCommunity(subcomm1subcomm2) + ))) + .andExpect(jsonPath("$.page.totalElements", is(3))); + + getClient(rootAdminToken).perform(get("/api/core/communities/search/findEditAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.communities", + Matchers.containsInAnyOrder( + CommunityMatcher.matchCommunity(root), + CommunityMatcher.matchCommunity(subcomm1), + CommunityMatcher.matchCommunity(subcomm1subcomm2) + ))) + .andExpect(jsonPath("$.page.totalElements", is(3))); + + getClient(subcomm1AdminToken).perform(get("/api/core/communities/search/findEditAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.communities", + Matchers.containsInAnyOrder( + CommunityMatcher.matchCommunity(subcomm1), + CommunityMatcher.matchCommunity(subcomm1subcomm2) + ))) + .andExpect(jsonPath("$.page.totalElements", is(2))); + + getClient(subcomm2AdminToken).perform(get("/api/core/communities/search/findEditAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.communities", + Matchers.containsInAnyOrder( + CommunityMatcher.matchCommunity(subcomm1subcomm2) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + + } + + @Test + public void findAddAuthorizedUnauthorizedTest() throws Exception { + getClient().perform(get("/api/core/communities/search/findAddAuthorized")) + .andExpect(status().isUnauthorized()); + } + + @Test + public void findAddAuthorizedResourcePolicyTest() throws Exception { + context.turnOffAuthorisationSystem(); + Community comm1 = CommunityBuilder.createCommunity(context).withName("Community 1").build(); + + EPerson hasDirectEditRights = EPersonBuilder.createEPerson(context) + .withEmail("has@editrights.com").withPassword(password) + .build(); + EPerson hasDirectAdminRights = EPersonBuilder.createEPerson(context) + .withEmail("has@adminrights.com").withPassword(password) + .build(); + Community byResourcePolicy = CommunityBuilder.createCommunity(context) + .withName("direct add rights for eperson").build(); + Community uneditable = CommunityBuilder.createCommunity(context) + .withName("no add community") + .build(); + ResourcePolicy policy = ResourcePolicyBuilder.createResourcePolicy(context, hasDirectEditRights, null) + .withDspaceObject(byResourcePolicy).withAction(Constants.ADD) + .build(); + policy = ResourcePolicyBuilder.createResourcePolicy(context, hasDirectAdminRights, null) + .withDspaceObject(byResourcePolicy).withAction(Constants.ADMIN) + .build(); + context.restoreAuthSystemState(); + + String tokenHasDirectAddRightsToken = getAuthToken(hasDirectEditRights.getEmail(), password); + String tokenHasDirectAdminRightsToken = getAuthToken(hasDirectAdminRights.getEmail(), password); + + getClient(tokenHasDirectAddRightsToken).perform(get("/api/core/communities/search/findAddAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.communities", + Matchers.contains(CommunityMatcher.matchCommunity(byResourcePolicy)))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + getClient(tokenHasDirectAdminRightsToken).perform(get("/api/core/communities/search/findAddAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.communities", + Matchers.contains(CommunityMatcher.matchCommunity(byResourcePolicy)))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + } + + @Test + public void findAddAuthorizedAdminPropagationTest() throws Exception { + + context.turnOffAuthorisationSystem(); + + /* + DSO structure: + root + └── subcomm1 + └── subcomm2 + */ + EPerson rootAdmin = EPersonBuilder.createEPerson(context) + .withEmail("root@admin.com").withPassword(password).build(); + EPerson subcomm1Admin = EPersonBuilder.createEPerson(context) + .withEmail("subcomm1@admin.com").withPassword(password).build(); + EPerson subcomm2Admin = EPersonBuilder.createEPerson(context) + .withEmail("subcomm2@admin.com").withPassword(password).build(); + + Community root = CommunityBuilder.createCommunity(context) + .withAdminGroup(rootAdmin) + .withName("root") + .build(); + Community subcomm1 = CommunityBuilder.createSubCommunity(context, root) + .withAdminGroup(subcomm1Admin) + .withName("subcomm1") + .build(); + Community subcomm2 = CommunityBuilder.createSubCommunity(context, subcomm1) + .withAdminGroup(subcomm2Admin) + .withName("subcomm2") + .build(); + context.restoreAuthSystemState(); + + String siteAdminToken = getAuthToken(admin.getEmail(), password); + String rootAdminToken = getAuthToken(rootAdmin.getEmail(), password); + String subcomm1AdminToken = getAuthToken(subcomm1Admin.getEmail(), password); + String subcomm2AdminToken = getAuthToken(subcomm2Admin.getEmail(), password); + + getClient(siteAdminToken).perform(get("/api/core/communities/search/findAddAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.communities", + Matchers.containsInAnyOrder( + CommunityMatcher.matchCommunity(root), + CommunityMatcher.matchCommunity(subcomm1), + CommunityMatcher.matchCommunity(subcomm2) + ))) + .andExpect(jsonPath("$.page.totalElements", is(3))); + + getClient(rootAdminToken).perform(get("/api/core/communities/search/findAddAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.communities", + Matchers.containsInAnyOrder( + CommunityMatcher.matchCommunity(root), + CommunityMatcher.matchCommunity(subcomm1), + CommunityMatcher.matchCommunity(subcomm2) + ))) + .andExpect(jsonPath("$.page.totalElements", is(3))); + + getClient(subcomm1AdminToken).perform(get("/api/core/communities/search/findAddAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.communities", + Matchers.containsInAnyOrder( + CommunityMatcher.matchCommunity(subcomm1), + CommunityMatcher.matchCommunity(subcomm2) + ))) + .andExpect(jsonPath("$.page.totalElements", is(2))); + + getClient(subcomm2AdminToken).perform(get("/api/core/communities/search/findAddAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.communities", + Matchers.containsInAnyOrder( + CommunityMatcher.matchCommunity(subcomm2) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + } + + @Test + public void findEditAuthorizedCommunitiesWithQueryTest() throws Exception { + findGenericAuthorizedCommunitiesWithQueryTest("findEditAuthorized"); + } + + @Test + public void findAddAuthorizedCommunitiesWithQueryTest() throws Exception { + findGenericAuthorizedCommunitiesWithQueryTest("findAddAuthorized"); + } + + @Test + public void findReadAuthorizedCommunitiesWithQueryTest() throws Exception { + findGenericAuthorizedCommunitiesWithQueryTest("findAdminAuthorized"); + } + + public void findGenericAuthorizedCommunitiesWithQueryTest(String method) throws Exception { + + context.turnOffAuthorisationSystem(); + + EPerson eperson2 = EPersonBuilder.createEPerson(context) + .withEmail("eperson2@mail.com") + .withPassword(password) + .build(); + + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Community child1 = CommunityBuilder.createSubCommunity(context, parentCommunity) + .withName("Sub Community") + .build(); + Community child2 = CommunityBuilder.createSubCommunity(context, parentCommunity) + .withName("Sub Community Two") + .build(); + Community com1 = CommunityBuilder.createSubCommunity(context, parentCommunity) + .withName("Sample community") + .withAdminGroup(eperson) + .build(); + Community com2 = CommunityBuilder.createSubCommunity(context, child1) + .withName("Test community") + .build(); + Community com3 = CommunityBuilder.createSubCommunity(context, child2) + .withName("community of sample items") + .withAdminGroup(eperson) + .build(); + Community com4 = CommunityBuilder.createSubCommunity(context, child2) + .withName("Testing autocomplete in community") + .withAdminGroup(eperson2) + .build(); + Community com5 = CommunityBuilder.createSubCommunity(context, child2) + .withName("Title: subtitle (special characters)") + .build(); + context.restoreAuthSystemState(); + + // Test simple query + String tokenEPerson = getAuthToken(eperson.getEmail(), password); + getClient(tokenEPerson).perform(get("/api/core/communities/search/" + method) + .param("query", "community")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.communities", Matchers.containsInAnyOrder( + CommunityMatcher.matchProperties(com1.getName(), com1.getID(), com1.getHandle()), + CommunityMatcher.matchProperties(com3.getName(), com3.getID(), com3.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(2))); + + // Test case insensitive query + getClient(tokenEPerson).perform(get("/api/core/communities/search/" + method) + .param("query", "COMMUNITY")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.communities", Matchers.containsInAnyOrder( + CommunityMatcher.matchProperties(com1.getName(), com1.getID(), com1.getHandle()), + CommunityMatcher.matchProperties(com3.getName(), com3.getID(), com3.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(2))); + + // Test word for unauthorized community + getClient(tokenEPerson).perform(get("/api/core/communities/search/" + method) + .param("query", "test")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page.totalElements", is(0))); + + // Test eperson with no authorized communities + getClient(tokenEPerson).perform(get("/api/core/communities/search/" + method) + .param("query", "auto")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page.totalElements", is(0))); + + String tokenEPerson2 = getAuthToken(eperson2.getEmail(), password); + // Test eperson2 gets only their authorized community + getClient(tokenEPerson2).perform(get("/api/core/communities/search/" + method) + .param("query", "auto")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.communities", Matchers.contains( + CommunityMatcher.matchProperties(com4.getName(), com4.getID(), com4.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + + // Test query with multiple words + getClient(tokenEPerson2).perform(get("/api/core/communities/search/" + method) + .param("query", "testing auto")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.communities", Matchers.containsInAnyOrder( + CommunityMatcher.matchProperties(com4.getName(), com4.getID(), com4.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + + // Test as admin + String tokenAdmin = getAuthToken(admin.getEmail(), password); + getClient(tokenAdmin).perform(get("/api/core/communities/search/" + method) + .param("query", "sample")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.communities", Matchers.containsInAnyOrder( + CommunityMatcher.matchProperties(com1.getName(), com1.getID(), com1.getHandle()), + CommunityMatcher.matchProperties(com3.getName(), com3.getID(), com3.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(2))); + + // Test more specific unsorted query words + getClient(tokenAdmin).perform(get("/api/core/communities/search/" + method) + .param("query", "items sample")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.communities", Matchers.contains( + CommunityMatcher.matchProperties(com3.getName(), com3.getID(), com3.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + + // Test add retrieve the col not authorized to community admin user + getClient(tokenAdmin).perform(get("/api/core/communities/search/" + method) + .param("query", "test")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.communities", Matchers.containsInAnyOrder( + CommunityMatcher.matchProperties(com2.getName(), com2.getID(), com2.getHandle()), + CommunityMatcher.matchProperties(com4.getName(), com4.getID(), com4.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(2))); + + // Test query with special characters + getClient(tokenAdmin).perform(get("/api/core/communities/search/" + method) + .param("query", "title: subtitle")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.communities", Matchers.contains( + CommunityMatcher.matchProperties(com5.getName(), com5.getID(), com5.getHandle()) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + + } + } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/DiscoveryRestControllerIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/DiscoveryRestControllerIT.java index 7bf4fb1d6c1..e77c75ba51b 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/DiscoveryRestControllerIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/DiscoveryRestControllerIT.java @@ -107,6 +107,28 @@ public class DiscoveryRestControllerIT extends AbstractControllerIntegrationTest List> customSortFields = List.of( ); + /** + * Original value of the discovery.highlights.escape-html property, saved here to restore it after running the + * tests. + */ + boolean escapeHTML; + + @Override + public void setUp() throws Exception { + super.setUp(); + context.turnOffAuthorisationSystem(); + escapeHTML = configurationService.getBooleanProperty("discovery.highlights.escape-html"); + context.restoreAuthSystemState(); + } + + @Override + public void destroy() throws Exception { + context.turnOffAuthorisationSystem(); + configurationService.setProperty("discovery.highlights.escape-html", escapeHTML); + context.restoreAuthSystemState(); + super.destroy(); + } + @Test public void rootDiscoverTest() throws Exception { @@ -6871,4 +6893,59 @@ public void discoverSearchObjectsSupervisionConfigurationTest() throws Exception .andExpect(jsonPath("$._links.self.href", containsString("/api/discover/search/objects"))); } + @Test + public void discoverSearchObjectsFirstEscapeHTMLTagsBeforeApplyingHitHighlights() throws Exception { + context.turnOffAuthorisationSystem(); + configurationService.setProperty("discovery.highlights.escape-html", true); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection") + .build(); + + ItemBuilder.createItem(context, col1) + .withTitle("This is a test title") + .build(); + context.restoreAuthSystemState(); + + // This test proves that the HTML tags that are in the original metadata, like test, + // are now escaped and should be returned like <a>test</a> + // Only after this happens should the hit highlights be applied + getClient().perform(get("/api/discover/search/objects") + .param("query", "title")) + .andExpect(status().isOk()) + .andExpect(jsonPath( + "$._embedded.searchResult._embedded.objects[0].hitHighlights['dc.title']", + contains("This is a <a>test</a> title"))); + } + + @Test + public void discoverSearchObjectsDontEscapeHTMLTagsBeforeApplyingHitHighlights() throws Exception { + context.turnOffAuthorisationSystem(); + configurationService.setProperty("discovery.highlights.escape-html", false); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection") + .build(); + + ItemBuilder.createItem(context, col1) + .withTitle("This is a test title") + .build(); + context.restoreAuthSystemState(); + + // This test proves that the HTML tags that are in the original metadata, like test, + // are not escaped and should be returned like test + // Only after this happens should the hit highlights be applied + getClient().perform(get("/api/discover/search/objects") + .param("query", "title")) + .andExpect(status().isOk()) + .andExpect(jsonPath( + "$._embedded.searchResult._embedded.objects[0].hitHighlights['dc.title']", + contains("This is a test title"))); + } } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ItemRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ItemRestRepositoryIT.java index 14d0be8202c..f6d0825cc89 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ItemRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ItemRestRepositoryIT.java @@ -26,6 +26,8 @@ import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; +import static org.springframework.data.rest.webmvc.RestMediaTypes.TEXT_URI_LIST_VALUE; +import static org.springframework.http.MediaType.parseMediaType; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; @@ -56,6 +58,7 @@ import org.dspace.app.rest.matcher.CollectionMatcher; import org.dspace.app.rest.matcher.HalMatcher; import org.dspace.app.rest.matcher.ItemMatcher; +import org.dspace.app.rest.model.GroupRest; import org.dspace.app.rest.model.ItemRest; import org.dspace.app.rest.model.MetadataRest; import org.dspace.app.rest.model.MetadataValueRest; @@ -5217,4 +5220,446 @@ public void copyApproximateDateIntoDateIssued_whenApproximateDateHasRangeOfValue matchMetadata("dc.date.issued", "2022")))); } + @Test + public void findEditAuthorizedUnauthorizedTest() throws Exception { + getClient().perform(get("/api/core/items/search/findEditAuthorized")) + .andExpect(status().isUnauthorized()); + } + + @Test + public void findEditAuthorizedResourcePolicyTest() throws Exception { + context.turnOffAuthorisationSystem(); + Community comm1 = CommunityBuilder.createCommunity(context).withName("Community 1").build(); + Collection col1 = CollectionBuilder.createCollection(context, comm1).withName("Collection 1").build(); + + EPerson hasDirectEditRights = EPersonBuilder.createEPerson(context) + .withEmail("has@editrights.com").withPassword(password) + .build(); + EPerson hasDirectAdminRights = EPersonBuilder.createEPerson(context) + .withEmail("has@adminrights.com").withPassword(password) + .build(); + Item byResourcePolicy = ItemBuilder.createItem(context, col1) + .withTitle("direct edit rights for eperson") + .build(); + Item uneditable = ItemBuilder.createItem(context, col1) + .withTitle("uneditable item") + .build(); + ResourcePolicy policy = ResourcePolicyBuilder.createResourcePolicy(context, hasDirectEditRights, null) + .withDspaceObject(byResourcePolicy).withAction(WRITE) + .build(); + policy = ResourcePolicyBuilder.createResourcePolicy(context, hasDirectAdminRights, null) + .withDspaceObject(byResourcePolicy).withAction(Constants.ADMIN) + .build(); + context.restoreAuthSystemState(); + + String tokenHasDirectEditRightsToken = getAuthToken(hasDirectEditRights.getEmail(), password); + String tokenHasDirectAdminRightsToken = getAuthToken(hasDirectAdminRights.getEmail(), password); + + getClient(tokenHasDirectEditRightsToken).perform(get("/api/core/items/search/findEditAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.items", + Matchers.contains(ItemMatcher.matchItemProperties(byResourcePolicy)))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + getClient(tokenHasDirectAdminRightsToken).perform(get("/api/core/items/search/findEditAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.items", + Matchers.contains(ItemMatcher.matchItemProperties(byResourcePolicy)))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + } + + @Test + public void findEditAuthorizedAdminPropagationTest() throws Exception { + /* + Cases: + - items in collection with admin rights + - items in collection in community with admin rights + */ + + context.turnOffAuthorisationSystem(); + + /* + DSO structure: + root + ├── subcomm1 + ├── subcomm1collA + ├── subcomm1collAitemX + ├── subcomm1collAitemY + ├── subcomm1collB + └── subcomm1collBitem + └── subcomm2 + └── subcomm2coll + └── subcomm2collitem + */ + EPerson rootAdmin = EPersonBuilder.createEPerson(context) + .withEmail("root@admin.com").withPassword(password).build(); + EPerson subcomm1Admin = EPersonBuilder.createEPerson(context) + .withEmail("subcomm1@admin.com").withPassword(password).build(); + EPerson subcomm2Admin = EPersonBuilder.createEPerson(context) + .withEmail("subcomm2@admin.com").withPassword(password).build(); + EPerson subcomm1collA_Admin = EPersonBuilder.createEPerson(context) + .withEmail("subcomm1collA@admin.com").withPassword(password).build(); + EPerson subcomm1collB_Admin = EPersonBuilder.createEPerson(context) + .withEmail("subcomm1collB@admin.com").withPassword(password).build(); + EPerson subcomm2collAdmin = EPersonBuilder.createEPerson(context) + .withEmail("subcomm2coll@admin.com").withPassword(password).build(); + + Community root = CommunityBuilder.createCommunity(context) + .withAdminGroup(rootAdmin) + .withName("root") + .build(); + Community subcomm1 = CommunityBuilder.createSubCommunity(context, root) + .withAdminGroup(subcomm1Admin) + .withName("subcomm1") + .build(); + Community subcomm2 = CommunityBuilder.createSubCommunity(context, root) + .withAdminGroup(subcomm2Admin) + .withName("subcomm2") + .build(); + Collection subcomm1collA = CollectionBuilder.createCollection(context, subcomm1) + .withAdminGroup(subcomm1collA_Admin) + .withName("subcomm1collA") + .build(); + Collection subcomm1collB = CollectionBuilder.createCollection(context, subcomm1) + .withAdminGroup(subcomm1collB_Admin) + .withName("subcomm1collB") + .build(); + Collection subcomm2coll = CollectionBuilder.createCollection(context, subcomm2) + .withAdminGroup(subcomm2collAdmin) + .withName("subcomm2coll") + .build(); + Item subcomm1collAitemX = ItemBuilder.createItem(context, subcomm1collA).withTitle("subcomm1collAitemX") + .build(); + Item subcomm1collAitemY = ItemBuilder.createItem(context, subcomm1collA).withTitle("subcomm1collAitemY") + .build(); + Item subcomm1collBitem = ItemBuilder.createItem(context, subcomm1collB).withTitle("subcomm1collBitem") + .build(); + Item subcomm2collitem = ItemBuilder.createItem(context, subcomm2coll).withTitle("subcomm2collitem") + .build(); + context.restoreAuthSystemState(); + + String siteAdminToken = getAuthToken(admin.getEmail(), password); + String rootAdminToken = getAuthToken(rootAdmin.getEmail(), password); + String subcomm1AdminToken = getAuthToken(subcomm1Admin.getEmail(), password); + String subcomm2AdminToken = getAuthToken(subcomm2Admin.getEmail(), password); + String subcomm1collA_AdminToken = getAuthToken(subcomm1collA_Admin.getEmail(), password); + String subcomm1collB_AdminToken = getAuthToken(subcomm1collB_Admin.getEmail(), password); + String subcomm2collAdminToken = getAuthToken(subcomm2collAdmin.getEmail(), password); + + getClient(siteAdminToken).perform(get("/api/core/items/search/findEditAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.items", + Matchers.containsInAnyOrder( + ItemMatcher.matchItemProperties(subcomm1collAitemX), + ItemMatcher.matchItemProperties(subcomm1collAitemY), + ItemMatcher.matchItemProperties(subcomm1collBitem), + ItemMatcher.matchItemProperties(subcomm2collitem) + ))) + .andExpect(jsonPath("$.page.totalElements", is(4))); + + getClient(rootAdminToken).perform(get("/api/core/items/search/findEditAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.items", + Matchers.containsInAnyOrder( + ItemMatcher.matchItemProperties(subcomm1collAitemX), + ItemMatcher.matchItemProperties(subcomm1collAitemY), + ItemMatcher.matchItemProperties(subcomm1collBitem), + ItemMatcher.matchItemProperties(subcomm2collitem) + ))) + .andExpect(jsonPath("$.page.totalElements", is(4))); + + getClient(subcomm1AdminToken).perform(get("/api/core/items/search/findEditAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.items", + Matchers.containsInAnyOrder( + ItemMatcher.matchItemProperties(subcomm1collAitemX), + ItemMatcher.matchItemProperties(subcomm1collAitemY), + ItemMatcher.matchItemProperties(subcomm1collBitem) + ))) + .andExpect(jsonPath("$.page.totalElements", is(3))); + + getClient(subcomm2AdminToken).perform(get("/api/core/items/search/findEditAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.items", + Matchers.containsInAnyOrder( + ItemMatcher.matchItemProperties(subcomm2collitem) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + + getClient(subcomm1collA_AdminToken).perform(get("/api/core/items/search/findEditAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.items", + Matchers.containsInAnyOrder( + ItemMatcher.matchItemProperties(subcomm1collAitemX), + ItemMatcher.matchItemProperties(subcomm1collAitemY) + ))) + .andExpect(jsonPath("$.page.totalElements", is(2))); + + getClient(subcomm1collB_AdminToken).perform(get("/api/core/items/search/findEditAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.items", + Matchers.containsInAnyOrder( + ItemMatcher.matchItemProperties(subcomm1collBitem) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + + getClient(subcomm2collAdminToken).perform(get("/api/core/items/search/findEditAuthorized")) + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$._embedded.items", + Matchers.containsInAnyOrder( + ItemMatcher.matchItemProperties(subcomm2collitem) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + } + + @Test + public void addParentComAdminGroupToCheckReindexingTest() throws Exception { + context.turnOffAuthorisationSystem(); + + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity) + .withName("col1") + .build(); + + Item item = ItemBuilder.createItem(context, col1) + .withTitle("MyTest") + .build(); + + context.restoreAuthSystemState(); + + String epersonToken = getAuthToken(eperson.getEmail(), password); + getClient(epersonToken).perform(get("/api/core/items/search/findEditAuthorized") + .param("query", "MyTest")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded").doesNotExist()) + .andExpect(jsonPath("$.page.totalElements", is(0))); + + AtomicReference idRef = new AtomicReference<>(); + ObjectMapper mapper = new ObjectMapper(); + GroupRest groupRest = new GroupRest(); + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(post("/api/core/communities/" + parentCommunity.getID() + "/adminGroup") + .content(mapper.writeValueAsBytes(groupRest)) + .contentType(contentType)) + .andExpect(status().isCreated()) + .andDo(result -> idRef.set( + UUID.fromString(read(result.getResponse().getContentAsString(), "$.id"))) + ); + + String adminToken = getAuthToken(admin.getEmail(), password); + getClient(adminToken).perform(post("/api/eperson/groups/" + idRef.get() + "/epersons") + .contentType(parseMediaType(TEXT_URI_LIST_VALUE)) + .content(REST_SERVER_URL + "eperson/groups/" + eperson.getID() + )); + + getClient(epersonToken).perform(get("/api/core/items/search/findEditAuthorized") + .param("query", "MyTest")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.items", Matchers.contains(ItemMatcher + .matchItemProperties(item) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + } + + @Test + public void removeParentComAdminPolicyToCheckEditPropagationTest() throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + + ResourcePolicy policy = ResourcePolicyBuilder.createResourcePolicy(context, eperson, null) + .withDspaceObject(parentCommunity).withAction(Constants.ADMIN) + .build(); + + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity) + .withName("col1") + .build(); + + Item item = ItemBuilder.createItem(context, col1) + .withTitle("MyTest") + .build(); + + context.restoreAuthSystemState(); + + String epersonToken = getAuthToken(eperson.getEmail(), password); + getClient(epersonToken).perform(get("/api/core/items/search/findEditAuthorized") + .param("query", "MyTest")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.items", Matchers.contains(ItemMatcher + .matchItemProperties(item) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + + String token = getAuthToken(admin.getEmail(), password); + getClient(token).perform(delete("/api/authz/resourcepolicies/" + policy.getID())) + .andExpect(status().is(204)); + + getClient(epersonToken).perform(get("/api/core/items/search/findEditAuthorized") + .param("query", "MyTest")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded").doesNotExist()) + .andExpect(jsonPath("$.page.totalElements", is(0))); + } + + @Test + public void findEditAuthorizedItemsWithQueryTest() throws Exception { + findGenericAuthorizedItemsWithQueryTest("findEditAuthorized"); + } + + public void findGenericAuthorizedItemsWithQueryTest(String method) throws Exception { + + context.turnOffAuthorisationSystem(); + + EPerson eperson2 = EPersonBuilder.createEPerson(context) + .withEmail("eperson2@mail.com") + .withPassword(password) + .build(); + + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Community child1 = CommunityBuilder.createSubCommunity(context, parentCommunity) + .withName("Sub Community") + .build(); + Community child2 = CommunityBuilder.createSubCommunity(context, parentCommunity) + .withName("Sub Community Two") + .build(); + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Sample collection") + .withAdminGroup(eperson) + .build(); + Collection col2 = CollectionBuilder.createCollection(context, child1) + .withName("col2") + .build(); + Collection col3 = CollectionBuilder.createCollection(context, child2) + .withName("col3") + .withAdminGroup(eperson) + .build(); + Collection col4 = CollectionBuilder.createCollection(context, child2) + .withName("col4") + .withAdminGroup(eperson2) + .build(); + Item item1 = ItemBuilder.createItem(context, col1) + .withTitle("Sample item") + .build(); + Item item2 = ItemBuilder.createItem(context, col2) + .withTitle("Test item") + .build(); + Item item3 = ItemBuilder.createItem(context, col3) + .withTitle("Item of sample bitstreams") + .build(); + Item item4 = ItemBuilder.createItem(context, col4) + .withTitle("Testing autocomplete in items") + .build(); + Item item5 = ItemBuilder.createItem(context, col4) + .withTitle("Title: subtitle (special characters)") + .build(); + + context.restoreAuthSystemState(); + + // Test simple query + String tokenEPerson = getAuthToken(eperson.getEmail(), password); + getClient(tokenEPerson).perform(get("/api/core/items/search/" + method) + .param("query", "item")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.items", Matchers.containsInAnyOrder( + ItemMatcher.matchItemProperties(item1), + ItemMatcher.matchItemProperties(item3) + ))) + .andExpect(jsonPath("$.page.totalElements", is(2))); + + // Test case insensitive + getClient(tokenEPerson).perform(get("/api/core/items/search/" + method) + .param("query", "ITEM")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.items", Matchers.containsInAnyOrder( + ItemMatcher.matchItemProperties(item1), + ItemMatcher.matchItemProperties(item3) + ))) + .andExpect(jsonPath("$.page.totalElements", is(2))); + + // Test word for unauthorized item + getClient(tokenEPerson).perform(get("/api/core/items/search/" + method) + .param("query", "test")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page.totalElements", is(0))); + + // Test eperson with no authorized items + getClient(tokenEPerson).perform(get("/api/core/items/search/" + method) + .param("query", "auto")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page.totalElements", is(0))); + + String tokenEPerson2 = getAuthToken(eperson2.getEmail(), password); + // Test eperson2 with one authorized item + getClient(tokenEPerson2).perform(get("/api/core/items/search/" + method) + .param("query", "auto")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.items", Matchers.contains( + ItemMatcher.matchItemProperties(item4) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + + // Test query with multiple words + getClient(tokenEPerson2).perform(get("/api/core/items/search/" + method) + .param("query", "testing auto")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.items", Matchers.containsInAnyOrder( + ItemMatcher.matchItemProperties(item4) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + + // Test query as admin + String tokenAdmin = getAuthToken(admin.getEmail(), password); + getClient(tokenAdmin).perform(get("/api/core/items/search/" + method) + .param("query", "sample")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.items", Matchers.containsInAnyOrder( + ItemMatcher.matchItemProperties(item1), + ItemMatcher.matchItemProperties(item3) + ))) + .andExpect(jsonPath("$.page.totalElements", is(2))); + + // Test unsorted query words + getClient(tokenAdmin).perform(get("/api/core/items/search/" + method) + .param("query", "bitstreams sample")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.items", Matchers.contains( + ItemMatcher.matchItemProperties(item3) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + + // Test item not authorized for eperson is returned for admin + getClient(tokenAdmin).perform(get("/api/core/items/search/" + method) + .param("query", "test")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.items", Matchers.containsInAnyOrder( + ItemMatcher.matchItemProperties(item2), + ItemMatcher.matchItemProperties(item4) + ))) + .andExpect(jsonPath("$.page.totalElements", is(2))); + + // Test special characters in query + getClient(tokenAdmin).perform(get("/api/core/items/search/" + method) + .param("query", "title: subtitle (special")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.items", Matchers.contains( + ItemMatcher.matchItemProperties(item5) + ))) + .andExpect(jsonPath("$.page.totalElements", is(1))); + + } + } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/RequestItemRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/RequestItemRepositoryIT.java index fbbd179fd28..8c680f42767 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/RequestItemRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/RequestItemRepositoryIT.java @@ -13,6 +13,7 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @@ -58,6 +59,7 @@ import org.dspace.content.Bitstream; import org.dspace.content.Collection; import org.dspace.content.Item; +import org.dspace.content.factory.ContentServiceFactory; import org.dspace.services.ConfigurationService; import org.hamcrest.Matchers; import org.junit.Before; @@ -655,4 +657,88 @@ public void testGetLinkTokenEmailWithoutSubPath() throws MalformedURLException, assertEquals(expectedUrl, generatedLink); configurationService.reloadConfig(); } + + /** + * Test that deleting a bitstream also removes any {@link RequestItem} entities associated with it. + */ + @Test + public void testDeleteBitstreamRemovesRequestItem() throws Exception { + // Fake up a request in REST form. + RequestItemRest rir = new RequestItemRest(); + rir.setAllfiles(false); + rir.setItemId(item.getID().toString()); + rir.setBitstreamId(bitstream.getID().toString()); + rir.setRequestEmail(eperson.getEmail()); + rir.setRequestName(eperson.getFullName()); + rir.setRequestMessage(RequestItemBuilder.REQ_MESSAGE); + + // Create it and see if it was created correctly. + ObjectMapper mapper = new ObjectMapper(); + String authToken = getAuthToken(eperson.getEmail(), password); + + getClient(authToken) + .perform(post(URI_ROOT) + .content(mapper.writeValueAsBytes(rir)) + .contentType(contentType)) + .andExpect(status().isCreated()) + // verify the body is empty + .andExpect(jsonPath("$").doesNotExist()); + + // Verify the request item exists via findByBitstreamId before deletion + Iterator bitstreamRequests = requestItemService.findByBitstreamId(context, bitstream.getID()); + assertTrue("Request item should exist before bitstream deletion", bitstreamRequests.hasNext()); + + // Delete associated Bitstream + // Re-attach entities to the current Hibernate session: the REST POST above committed the + // context (detaching item/bitstream). BitstreamServiceImpl.delete runs CLARIN + // updateItemFilesMetadata, which mutates item metadata and requires an attached item. + bitstream = context.reloadEntity(bitstream); + item = context.reloadEntity(item); + ContentServiceFactory.getInstance().getBitstreamService().delete(context, bitstream); + + // Verify that all RequestItems related to this bitstream have been removed + Iterator itemRequests = requestItemService.findByItem(context, item); + assertFalse(itemRequests.hasNext()); + + // Also verify via findByBitstreamId + Iterator remaining = requestItemService.findByBitstreamId(context, bitstream.getID()); + assertFalse("Request items should be removed after bitstream deletion", remaining.hasNext()); + } + + /** + * Test that findByBitstreamId returns matching request items and does not return items for other bitstreams. + */ + @Test + public void testFindByBitstreamId() throws Exception { + context.turnOffAuthorisationSystem(); + + // Create a request item for the existing bitstream + RequestItemBuilder.createRequestItem(context, item, bitstream) + .build(); + + // Create a second bitstream with no request items + InputStream is2 = new ByteArrayInputStream("other content".getBytes()); + Bitstream bitstream2 = BitstreamBuilder + .createBitstream(context, item, is2) + .withName("Other Bitstream") + .build(); + + context.restoreAuthSystemState(); + + // findByBitstreamId should return the request for the first bitstream + Iterator results = requestItemService.findByBitstreamId(context, bitstream.getID()); + assertTrue("Should find request item for bitstream", results.hasNext()); + RequestItem found = results.next(); + assertEquals("Request item should reference correct bitstream", + bitstream.getID(), found.getBitstream().getID()); + assertFalse("Should only find one request item", results.hasNext()); + + // findByBitstreamId should return nothing for the second bitstream + Iterator noResults = requestItemService.findByBitstreamId(context, bitstream2.getID()); + assertFalse("Should find no request items for bitstream without requests", noResults.hasNext()); + + // findByBitstreamId should return nothing for a random UUID + Iterator randomResults = requestItemService.findByBitstreamId(context, UUID.randomUUID()); + assertFalse("Should find no request items for nonexistent bitstream", randomResults.hasNext()); + } } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ResearcherProfileRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ResearcherProfileRestRepositoryIT.java index 0d46f4268ca..7e1b0055bbf 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ResearcherProfileRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ResearcherProfileRestRepositoryIT.java @@ -143,6 +143,7 @@ public void setUp() throws Exception { user = EPersonBuilder.createEPerson(context) .withEmail("user@example.com") + .withNameInMetadata("Example", "User") .withPassword(password) .build(); @@ -322,7 +323,7 @@ public void testFindByIdWithoutOwnerUser() throws Exception { public void testCreateAndReturn() throws Exception { String id = user.getID().toString(); - String name = user.getName(); + String name = user.getFullName(); String authToken = getAuthToken(user.getEmail(), password); @@ -341,6 +342,8 @@ public void testCreateAndReturn() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.type", is("item"))) .andExpect(jsonPath("$.metadata", matchMetadata("dspace.object.owner", name, id, 0))) + .andExpect(jsonPath("$.metadata", matchMetadata("person.givenName", user.getFirstName(), 0))) + .andExpect(jsonPath("$.metadata", matchMetadata("person.familyName", user.getLastName(), 0))) .andExpect(jsonPath("$.metadata", matchMetadata("dspace.entity.type", "Person", 0))); getClient(authToken).perform(get("/api/eperson/profiles/{id}/eperson", id)) @@ -390,7 +393,7 @@ public void testCreateAndReturnWithPublicProfile() throws Exception { public void testCreateAndReturnWithAdmin() throws Exception { String id = user.getID().toString(); - String name = user.getName(); + String name = user.getFullName(); configurationService.setProperty("researcher-profile.collection.uuid", null); @@ -411,6 +414,8 @@ public void testCreateAndReturnWithAdmin() throws Exception { getClient(authToken).perform(get("/api/eperson/profiles/{id}/item", id)) .andExpect(status().isOk()) .andExpect(jsonPath("$.type", is("item"))) + .andExpect(jsonPath("$.metadata", matchMetadata("person.givenName", user.getFirstName(), 0))) + .andExpect(jsonPath("$.metadata", matchMetadata("person.familyName", user.getLastName(), 0))) .andExpect(jsonPath("$.metadata", matchMetadata("dspace.object.owner", name, id, 0))) .andExpect(jsonPath("$.metadata", matchMetadata("dspace.entity.type", "Person", 0))); diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/SubmissionDefinitionsControllerIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/SubmissionDefinitionsControllerIT.java index 404593449d5..8111e019dc3 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/SubmissionDefinitionsControllerIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/SubmissionDefinitionsControllerIT.java @@ -190,8 +190,7 @@ public void findCollections() throws Exception { //Match only that a section exists with a submission configuration behind getClient(token).perform(get("/api/config/submissiondefinitions/traditional/collections") .param("projection", "full")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.page.totalElements", is(0))); + .andExpect(status().isNoContent()); } @Test diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/SubmissionDefinitionsMatcher.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/SubmissionDefinitionsMatcher.java index 398097db6fb..14d8e514381 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/SubmissionDefinitionsMatcher.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/SubmissionDefinitionsMatcher.java @@ -34,7 +34,7 @@ public static Matcher matchSubmissionDefinition(boolean isDefault, Strin */ public static Matcher matchFullEmbeds() { return matchEmbeds( - "collections[]", + "collections", "sections" ); } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/security/jwt/ShortLivedJWTTokenHandlerTest.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/security/jwt/ShortLivedJWTTokenHandlerTest.java index 70497218652..ab3307fdaf9 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/security/jwt/ShortLivedJWTTokenHandlerTest.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/security/jwt/ShortLivedJWTTokenHandlerTest.java @@ -76,8 +76,7 @@ public void testJWTEncrypted() throws Exception { //temporary set a negative expiration time so the token is invalid immediately @Test public void testExpiredToken() throws Exception { - when(configurationService.getLongProperty("jwt.shortLived.token.expiration", 1800000)) - .thenReturn(-99999999L); + when(shortLivedJWTTokenHandler.getExpirationPeriod()).thenReturn(-99999999L); when(ePersonClaimProvider.getEPerson(any(Context.class), any(JWTClaimsSet.class))).thenReturn(ePerson); Date previous = new Date(new Date().getTime() - 10000000000L); String token = shortLivedJWTTokenHandler diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/signposting/controller/LinksetRestControllerIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/signposting/controller/LinksetRestControllerIT.java index a65357f97bf..167fbeb9e3f 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/signposting/controller/LinksetRestControllerIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/signposting/controller/LinksetRestControllerIT.java @@ -8,6 +8,7 @@ package org.dspace.app.rest.signposting.controller; import static org.dspace.content.MetadataSchemaEnum.PERSON; +import static org.junit.Assert.assertTrue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; @@ -18,7 +19,9 @@ import java.text.DateFormat; import java.text.MessageFormat; import java.text.SimpleDateFormat; +import java.util.ArrayList; import java.util.Date; +import java.util.UUID; import org.apache.commons.codec.CharEncoding; import org.apache.commons.io.IOUtils; @@ -691,6 +694,61 @@ public void findTypedLinkForItemWithAuthor() throws Exception { "&& @.type == 'application/linkset+json')]").exists()); } + @Test + public void showTypedLinksMissingForItemWithMoreBitstreamsThanLimit() throws Exception { + String bitstreamContent = "ThisIsSomeDummyText"; + String bitstreamMimeType = "text/plain"; + + int itemBitstreamsLimit = configurationService.getIntProperty("signposting.item.bitstreams.limit", 10); + + context.turnOffAuthorisationSystem(); + Item item = ItemBuilder.createItem(context, collection) + .withTitle("Item Test") + .withMetadata("dc", "identifier", "doi", doi) + .build(); + + // Add more bitstreams than the configured limit + ArrayList bitstreamIDs = new ArrayList<>(); + for (int i = 0; i <= itemBitstreamsLimit; i++) { + Bitstream bitstream = null; + try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { + bitstream = BitstreamBuilder.createBitstream(context, item, is) + .withName("Bitstream " + i) + .withDescription("description") + .withMimeType(bitstreamMimeType) + .build(); + + if (bitstream != null) { + bitstreamIDs.add(bitstream.getID()); + } + } + } + context.restoreAuthSystemState(); + + // Make sure the bitstreams were successfully added. + assertTrue("There was a problem ingesting bitstreams.", bitstreamIDs.size() > itemBitstreamsLimit); + + String url = configurationService.getProperty("dspace.ui.url"); + String signpostingUrl = configurationService.getProperty("signposting.path"); + + // There should be typed links to the Link Sets but no typed links to the Bitstreams in the response. + // We only need to check for one of the Bitstream UUIDs, since all of them should be absent. + UUID firstBitstreamId = bitstreamIDs.get(0); + getClient().perform(get("/signposting/links/" + item.getID())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[?(@.href == '" + url + "/" + signpostingUrl + "/linksets/" + + item.getID().toString() + "' " + + "&& @.rel == 'linkset' " + + "&& @.type == 'application/linkset')]").exists()) + .andExpect(jsonPath("$[?(@.href == '" + url + "/" + signpostingUrl + "/linksets/" + + item.getID().toString() + "/json' " + + "&& @.rel == 'linkset' " + + "&& @.type == 'application/linkset+json')]").exists()) + .andExpect(jsonPath("$[?(@.href == '" + url + "/bitstreams/" + firstBitstreamId + "/download' " + + "&& @.rel == 'item' " + + "&& @.type == 'text/plain')]").doesNotExist());; + } + @Test public void findTypedLinkForBitstream() throws Exception { String bitstreamContent = "ThisIsSomeDummyText"; diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/test/WebappLoggingIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/test/WebappLoggingIT.java new file mode 100644 index 00000000000..fe746452c79 --- /dev/null +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/test/WebappLoggingIT.java @@ -0,0 +1,101 @@ +/** + * 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.test; + +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.core.Appender; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.Configuration; +import org.apache.logging.log4j.core.config.LoggerConfig; +import org.apache.logging.log4j.core.config.Property; +import org.apache.logging.log4j.core.layout.PatternLayout; +import org.junit.After; +import org.junit.Test; + +/** + * Test basic log4j logging functionality, extending AbstractControllerIntegrationTest + * purely to make sure we are testing the *web application* and not just the kernel + * as that is where logging has broken in the past. + * + * @author Kim Shepherd + */ +public class WebappLoggingIT extends AbstractControllerIntegrationTest { + + private static final Logger logger = LogManager.getLogger(WebappLoggingIT.class); + private static final String APPENDER_NAME = "DSpaceTestAppender"; + + static class InMemoryAppender extends AbstractAppender { + private final List messages = new ArrayList<>(); + + protected InMemoryAppender(String name) { + super( + name, + null, + PatternLayout.newBuilder().withPattern("%m").build(), + false, + Property.EMPTY_ARRAY + ); + start(); + } + + @Override + public void append(LogEvent event) { + messages.add(event.getMessage().getFormattedMessage()); + } + + public List getMessages() { + return messages; + } + } + + @Test + public void testLogging() throws Exception { + LoggerContext context = (LoggerContext) LogManager.getContext(false); + Configuration config = context.getConfiguration(); + + InMemoryAppender appender = new InMemoryAppender(APPENDER_NAME); + config.addAppender(appender); + + LoggerConfig testLoggerConfig = new LoggerConfig(logger.getName(), Level.INFO, false); + testLoggerConfig.addAppender(appender, null, null); + config.addLogger(logger.getName(), testLoggerConfig); + context.updateLoggers(); + + logger.info("DSPACE TEST LOG ENTRY"); + + List messages = appender.getMessages(); + assertTrue(messages.stream().anyMatch(msg -> msg.contains("DSPACE TEST LOG ENTRY"))); + } + + @After + public void cleanupAppender() { + LoggerContext context = (LoggerContext) LogManager.getContext(false); + Configuration config = context.getConfiguration(); + + config.removeLogger(logger.getName()); + + Appender appender = config.getAppender(APPENDER_NAME); + if (appender != null) { + appender.stop(); + config.getAppenders().remove(APPENDER_NAME); + } + + context.updateLoggers(); +} + +} + diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/sword/Swordv1IT.java b/dspace-server-webapp/src/test/java/org/dspace/app/sword/Swordv1IT.java index 24244e1773e..29ea05dacab 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/sword/Swordv1IT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/sword/Swordv1IT.java @@ -10,16 +10,30 @@ import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.equalTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThat; +import java.nio.file.Path; +import java.util.List; + import org.dspace.app.rest.test.AbstractWebClientIntegrationTest; +import org.dspace.builder.CollectionBuilder; +import org.dspace.builder.CommunityBuilder; +import org.dspace.content.Collection; import org.dspace.services.ConfigurationService; +import org.hamcrest.MatcherAssert; import org.junit.Assume; import org.junit.Before; -import org.junit.Ignore; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.FileSystemResource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; import org.springframework.http.ResponseEntity; import org.springframework.test.context.TestPropertySource; @@ -45,6 +59,9 @@ public class Swordv1IT extends AbstractWebClientIntegrationTest { private final String DEPOSIT_PATH = "/sword/deposit"; private final String MEDIA_LINK_PATH = "/sword/media-link"; + // ATOM Content type returned by SWORDv1 + private final String ATOM_CONTENT_TYPE = "application/atom+xml;charset=UTF-8"; + @Before public void onlyRunIfConfigExists() { // These integration tests REQUIRE that SWORDWebConfig is found/available (as this class deploys SWORD) @@ -93,10 +110,76 @@ public void depositUnauthorizedTest() throws Exception { } @Test - @Ignore public void depositTest() throws Exception { - // TODO: Actually test a full deposit via SWORD. - // Currently, we are just ensuring the /deposit endpoint exists (see above) and isn't throwing a 404 + context.turnOffAuthorisationSystem(); + // Create a top level community and one Collection + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + // Make sure our Collection allows the "eperson" user to submit into it + Collection collection = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Test SWORDv1 Collection") + .withSubmitterGroup(eperson) + .build(); + // Above changes MUST be committed to the database for SWORDv2 to see them. + context.commit(); + context.restoreAuthSystemState(); + + // Specify zip file + // NOTE: We are using the same "example.zip" as SWORDv2IT because that same ZIP is valid for both v1 and v2 + FileSystemResource zipFile = new FileSystemResource(Path.of("src", "test", "resources", "org", + "dspace", "app", "sword2", "example.zip")); + + // Add required headers + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.valueOf("application/zip")); + headers.setContentDisposition(ContentDisposition.attachment().filename("example.zip").build()); + headers.set("X-Packaging", "http://purl.org/net/sword-types/METSDSpaceSIP"); + headers.setAccept(List.of(MediaType.APPLICATION_ATOM_XML)); + + //---- + // STEP 1: Verify upload/submit via SWORDv1 works + //---- + // Send POST to upload Zip file via SWORD + ResponseEntity response = postResponseAsString(DEPOSIT_PATH + "/" + collection.getHandle(), + eperson.getEmail(), password, + new HttpEntity<>(zipFile.getInputStream().readAllBytes(), + headers)); + + // Expect a 201 CREATED response with ATOM content returned + assertEquals(HttpStatus.CREATED, response.getStatusCode()); + assertEquals(ATOM_CONTENT_TYPE, response.getHeaders().getContentType().toString()); + + // MUST return a "Location" header which is the "/sword/media-link/*" URI of the zip file bitstream within + // the created item (e.g. /sword/media-link/[handle-prefix]/[handle-suffix]/bitstream/[uuid]) + assertNotNull(response.getHeaders().getLocation()); + String mediaLink = response.getHeaders().getLocation().toString(); + + // Body should include the SWORD version in generator tag + MatcherAssert.assertThat(response.getBody(), + containsString("")); + // Verify Item title also is returned in the body + MatcherAssert.assertThat(response.getBody(), containsString("Attempts to detect retrotransposition")); + + //---- + // STEP 2: Verify /media-link access works + //---- + // Media-Link URI should work when requested by the EPerson who did the deposit + HttpHeaders authHeaders = new HttpHeaders(); + authHeaders.setBasicAuth(eperson.getEmail(), password); + RequestEntity request = RequestEntity.get(mediaLink) + .accept(MediaType.valueOf("application/atom+xml")) + .headers(authHeaders) + .build(); + response = responseAsString(request); + + // Expect a 200 response with ATOM feed content returned + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(ATOM_CONTENT_TYPE, response.getHeaders().getContentType().toString()); + // Body should include a link to the zip bitstream in the newly created Item + // This just verifies "example.zip" exists in the body. + MatcherAssert.assertThat(response.getBody(), containsString("example.zip")); } @Test @@ -105,13 +188,8 @@ public void mediaLinkUnauthorizedTest() throws Exception { ResponseEntity response = getResponseAsString(MEDIA_LINK_PATH); // Expect a 401 response code assertThat(response.getStatusCode(), equalTo(HttpStatus.UNAUTHORIZED)); - } - @Test - @Ignore - public void mediaLinkTest() throws Exception { - // TODO: Actually test a /media-link request. - // Currently, we are just ensuring the /media-link endpoint exists (see above) and isn't throwing a 404 + //NOTE: An authorized /media-link test is performed in depositTest() above. } } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/sword2/Swordv2IT.java b/dspace-server-webapp/src/test/java/org/dspace/app/sword2/Swordv2IT.java index 7bbd0ed8226..88145662439 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/sword2/Swordv2IT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/sword2/Swordv2IT.java @@ -224,7 +224,8 @@ public void depositAndEditViaSwordTest() throws Exception { // Add required headers HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.MULTIPART_FORM_DATA); - headers.setContentDisposition(ContentDisposition.attachment().filename("example.zip").build()); + // Test the file with spaces or special characters in the name + headers.setContentDisposition(ContentDisposition.attachment().filename("example .zip").build()); headers.set("Packaging", "http://purl.org/net/sword/package/METSDSpaceSIP"); headers.setAccept(List.of(MediaType.APPLICATION_ATOM_XML)); diff --git a/dspace-services/pom.xml b/dspace-services/pom.xml index 7849381edfc..7f9015b0489 100644 --- a/dspace-services/pom.xml +++ b/dspace-services/pom.xml @@ -9,7 +9,7 @@ org.dspace dspace-parent - 7.6.5 + 7.6.7 diff --git a/dspace-services/src/main/java/org/dspace/services/ConfigurationService.java b/dspace-services/src/main/java/org/dspace/services/ConfigurationService.java index 526a518a091..7681553d416 100644 --- a/dspace-services/src/main/java/org/dspace/services/ConfigurationService.java +++ b/dspace-services/src/main/java/org/dspace/services/ConfigurationService.java @@ -251,6 +251,8 @@ public interface ConfigurationService { * Set a configuration property (setting) in the system. * Type is not important here since conversion happens automatically * when properties are requested. + *
+ * Note: use with care, the value will be reset when the configuration is reloaded! * * @param name the property name * @param value the property value (set this to null to clear out the property) diff --git a/dspace-sword/pom.xml b/dspace-sword/pom.xml index cd5b8a6af30..aec6677ffe4 100644 --- a/dspace-sword/pom.xml +++ b/dspace-sword/pom.xml @@ -15,7 +15,7 @@ org.dspace dspace-parent - 7.6.5 + 7.6.7 .. @@ -81,7 +81,7 @@ xom xom - 1.3.9 + 1.4.0 diff --git a/dspace-swordv2/pom.xml b/dspace-swordv2/pom.xml index 34a22026828..d80386698f2 100644 --- a/dspace-swordv2/pom.xml +++ b/dspace-swordv2/pom.xml @@ -13,7 +13,7 @@ org.dspace dspace-parent - 7.6.5 + 7.6.7 .. diff --git a/dspace-swordv2/src/main/java/org/dspace/sword2/SwordAuthenticator.java b/dspace-swordv2/src/main/java/org/dspace/sword2/SwordAuthenticator.java index 54b769388c6..e47c0f076b9 100644 --- a/dspace-swordv2/src/main/java/org/dspace/sword2/SwordAuthenticator.java +++ b/dspace-swordv2/src/main/java/org/dspace/sword2/SwordAuthenticator.java @@ -9,6 +9,7 @@ import java.sql.SQLException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Iterator; import java.util.List; @@ -618,31 +619,33 @@ public List getAllowedCollections( // short cut by obtaining the collections to which the authenticated user can submit List cols = collectionService.findAuthorized( - authContext, community, Constants.ADD); + authContext, community, Arrays.asList(Constants.ADD, Constants.ADMIN)); + List allowed = new ArrayList<>(); // now find out if the obo user is allowed to submit to any of these collections - for (Collection col : cols) { - boolean oboAllowed = false; - - // check for obo null - if (swordContext.getOnBehalfOf() == null) { - oboAllowed = true; - } - - // if we have not already determined that the obo user is ok to submit, look up the READ policy on the - // community. THis will include determining if the user is an administrator. - if (!oboAllowed) { - oboAllowed = authorizeService.authorizeActionBoolean( - swordContext.getOnBehalfOfContext(), col, - Constants.ADD); - } + if (swordContext.getOnBehalfOf() != null) { + for (Collection col : cols) { + boolean oboAllowed = false; + + //if we have not already determined that the obo user is ok to submit, + //look up the READ policy on the + // community. THis will include determining if the user is an administrator. + if (!oboAllowed) { + oboAllowed = authorizeService.authorizeActionBoolean( + swordContext.getOnBehalfOfContext(), col, + Constants.ADD); + } - // final check to see if we are allowed to READ - if (oboAllowed) { - allowed.add(col); + // final check to see if we are allowed to READ + if (oboAllowed) { + allowed.add(col); + } } + } else { + return cols; } + return allowed; } catch (SQLException e) { diff --git a/dspace/config/clarin-dspace.cfg b/dspace/config/clarin-dspace.cfg index a481beb856a..9411ab2e3af 100644 --- a/dspace/config/clarin-dspace.cfg +++ b/dspace/config/clarin-dspace.cfg @@ -344,3 +344,30 @@ user.registration = false # This option allows submitter to skip file upload step in the submission process webui.submit.upload.required = false + +# CLARIN/LINDAT allowlist for inline preview. DSpace 7.6.6 flipped this setting from a blocklist +# to an allowlist, so any MIME type not listed here is force-downloaded. LINDAT serves language +# resources, so plain-text corpora, audio, TIFF scans and CSV tables are listed here to keep them +# previewable instead of force-downloaded. +# HTML/XML/TEI/SVG/JS are deliberately NOT listed: inline delivery would re-open the XSS hole that +# the allowlist flip closed. TEI/XML use the sanitized CLARIN preview path instead. +# Matching is on the MIME type recorded in config/registries/bitstream-formats.xml, so .wav resolves +# to audio/x-wav and .ogg to video/ogg - both are listed. .flac has no registry entry at all, so it +# stays an Unknown format and is always downloaded; audio/flac here only takes effect once the format +# is registered (backlog). +# IMPORTANT - this allowlist is only half of the decision: BitstreamRestController also enforces +# webui.content_disposition_threshold, whose code default is 8 MB (dspace.cfg keeps it commented, +# same as vanilla). Files LARGER than that are sent as attachment even when their MIME type is +# listed here, which in practice covers most real audio/video and large TIFF scans. Raising the +# threshold widens the inline-render surface, so it is a deliberate deployment decision and is +# intentionally NOT changed here. +# The list below was derived from config/registries/bitstream-formats.xml, not hand-picked: under +# 7.6.5 every image/* was served inline unconditionally and 90 of 96 registered formats could be +# inline, so anything omitted here is a silent inline->attachment regression for LINDAT content. +# "text/plain; charset=utf-8" is listed verbatim because the allowlist is matched with exact string +# equality (unlike the banned list, which uses substring matching): the registry's "License" format +# carries that parameterised MIME type and has no file extensions, so a bare "text/plain" token can +# never match it and license bitstreams would always download. Registry entries whose MIME type +# carries parameters must therefore be listed in full - upstream should compare the MIME type with +# parameters stripped instead (backlog: upstream PR). +webui.content_disposition_inline = application/pdf, image/gif, image/jpeg, image/png, image/tiff, image/jp2, image/webp, image/avif, image/x-ms-bmp, audio/mpeg, audio/wav, audio/x-wav, audio/ogg, audio/flac, audio/x-aiff, audio/basic, audio/x-mpeg, video/mpeg, video/mp4, video/ogg, video/webm, video/quicktime, text/plain, text/plain; charset=utf-8, text/csv, text/vtt diff --git a/dspace/config/crosswalks/DIM2DataCite.xsl b/dspace/config/crosswalks/DIM2DataCite.xsl index 7add24bfa24..75e7bf7bc81 100644 --- a/dspace/config/crosswalks/DIM2DataCite.xsl +++ b/dspace/config/crosswalks/DIM2DataCite.xsl @@ -371,6 +371,9 @@ + + + diff --git a/dspace/config/crosswalks/oai/metadataFormats/oai_openaire.xsl b/dspace/config/crosswalks/oai/metadataFormats/oai_openaire.xsl index 905187494dd..dd1ff556c63 100644 --- a/dspace/config/crosswalks/oai/metadataFormats/oai_openaire.xsl +++ b/dspace/config/crosswalks/oai/metadataFormats/oai_openaire.xsl @@ -5,7 +5,7 @@ detailed in the LICENSE and NOTICE files at the root of the source tree and available online at - Developed by Paulo Graça + Developed by paulo-graca > https://www.openaire.eu/schema/repo-lit/4.0/openaire.xsd @@ -101,7 +101,7 @@ - + @@ -137,7 +137,7 @@ - + @@ -206,7 +206,7 @@ - + @@ -303,7 +303,7 @@ - @@ -369,7 +369,7 @@ + schemeURI="https://www.webofscience.com"> @@ -406,7 +406,7 @@ - + @@ -482,7 +482,7 @@ - + @@ -592,7 +592,7 @@ - + @@ -611,7 +611,7 @@ - + @@ -633,7 +633,7 @@ - + @@ -663,7 +663,7 @@ - + @@ -697,7 +697,7 @@ - + @@ -717,8 +717,8 @@ - - + + @@ -729,7 +729,7 @@ - + @@ -739,7 +739,7 @@ @@ -772,7 +772,7 @@ - + @@ -784,7 +784,7 @@ - + @@ -795,8 +795,8 @@ - - + + @@ -820,7 +820,7 @@ - + @@ -833,7 +833,7 @@ - + @@ -849,15 +849,15 @@ - + - - + + - - + + @@ -868,7 +868,7 @@ - + @@ -913,7 +913,7 @@ - + @@ -922,7 +922,7 @@ - + @@ -931,7 +931,7 @@ - + @@ -940,7 +940,7 @@ - + @@ -949,7 +949,7 @@ - + @@ -958,7 +958,7 @@ - + @@ -967,7 +967,7 @@ - + @@ -977,7 +977,7 @@ - + @@ -1039,7 +1039,7 @@ - + @@ -1051,7 +1051,7 @@ - + Available @@ -1393,7 +1393,7 @@ @@ -1429,7 +1429,7 @@ literature - + dataset @@ -1446,7 +1446,7 @@ This template will return the COAR Resource Type Vocabulary URI like http://purl.org/coar/resource_type/c_6501 based on a valued text like 'article' - https://openaire-guidelines-for-literature-repository-managers.readthedocs.io/en/v4.0.0/field_publicationtype.html#attribute-uri-m + https://openaire-guidelines-for-literature-repository-managers.readthedocs.io/en/4.0.1/field_publicationtype.html#attribute-uri-m --> @@ -1642,7 +1642,7 @@ like "open access" based on the values from DSpace Access Status mechanism like String 'open.access' please check class org.dspace.access.status.DefaultAccessStatusHelper for more information - https://openaire-guidelines-for-literature-repository-managers.readthedocs.io/en/v4.0.0/field_accessrights.html#definition-and-usage-instruction + https://openaire-guidelines-for-literature-repository-managers.readthedocs.io/en/4.0.1/field_accessrights.html#definition-and-usage-instruction --> @@ -1672,7 +1672,7 @@ This template will return the COAR Access Right Vocabulary URI like http://purl.org/coar/access_right/c_abf2 based on a value text like 'open access' - https://openaire-guidelines-for-literature-repository-managers.readthedocs.io/en/v4.0.0/field_accessrights.html#definition-and-usage-instruction + https://openaire-guidelines-for-literature-repository-managers.readthedocs.io/en/4.0.1/field_accessrights.html#definition-and-usage-instruction --> diff --git a/dspace/config/crosswalks/oai/transformers/openaire4.xsl b/dspace/config/crosswalks/oai/transformers/openaire4.xsl index cece890450b..e9be2d54edb 100644 --- a/dspace/config/crosswalks/oai/transformers/openaire4.xsl +++ b/dspace/config/crosswalks/oai/transformers/openaire4.xsl @@ -13,7 +13,7 @@ @@ -80,7 +80,7 @@ Normalizing dc.rights according to COAR Controlled Vocabulary for Access Rights (Version 1.0) (http://vocabularies.coar-repositories.org/documentation/access_rights/) available at - https://openaire-guidelines-for-literature-repository-managers.readthedocs.io/en/v4.0.0/field_accessrights.html#definition-and-usage-instruction + https://openaire-guidelines-for-literature-repository-managers.readthedocs.io/en/4.0.1/field_accessrights.html#definition-and-usage-instruction --> @@ -116,7 +116,7 @@ diff --git a/dspace/config/crosswalks/oai/xoai.xml b/dspace/config/crosswalks/oai/xoai.xml index 723aa02d731..c407284a93c 100644 --- a/dspace/config/crosswalks/oai/xoai.xml +++ b/dspace/config/crosswalks/oai/xoai.xml @@ -78,7 +78,7 @@ - This contexts complies with OpenAIRE Guidelines for Literature Repositories v4.0. + This contexts complies with OpenAIRE Guidelines for Institutional and Thematic Repository Managers v4.0. @@ -192,7 +192,7 @@ http://irdb.nii.ac.jp/oai/junii2-3-1.xsd oai_openaire diff --git a/dspace/config/crosswalks/orcid/mapConverter-dspace-to-orcid-publication-type.properties b/dspace/config/crosswalks/orcid/mapConverter-dspace-to-orcid-publication-type.properties index 953ddc60eef..a45465b0839 100644 --- a/dspace/config/crosswalks/orcid/mapConverter-dspace-to-orcid-publication-type.properties +++ b/dspace/config/crosswalks/orcid/mapConverter-dspace-to-orcid-publication-type.properties @@ -4,20 +4,21 @@ Article = journal-article Book = book Book\ chapter = book-chapter Dataset = data-set -Learning\ Object = other -Image = other -Image,\ 3-D = other -Map = other -Musical\ Score = other +Learning\ Object = learning-object +Image = image +Image,\ 3-D = image +Journal = journal-issue +Map = cartographic-material +Musical\ Score = musical-composition Plan\ or\ blueprint = other Preprint = preprint Presentation = other -Recording,\ acoustical = other -Recording,\ musical = other -Recording,\ oral = other +Recording,\ acoustical = sound +Recording,\ musical = sound +Recording,\ oral = sound Software = software -Technical\ Report = other -Thesis = other -Video = other +Technical\ Report = report +Thesis = dissertation-thesis +Video = moving-image Working\ Paper = working-paper -Other = other \ No newline at end of file +Other = other diff --git a/dspace/config/dspace.cfg b/dspace/config/dspace.cfg index add4564d547..14e2c1d86b8 100644 --- a/dspace/config/dspace.cfg +++ b/dspace/config/dspace.cfg @@ -964,6 +964,7 @@ registry.metadata.load = schema-publicationVolume-types.xml registry.metadata.load = openaire4-types.xml registry.metadata.load = dspace-types.xml registry.metadata.load = iiif-types.xml +registry.metadata.load = journal-types.xml ### CLARIN ### # This property cannot be added in the `clarin-dspace.cfg` because then some Unit tests are failing.. registry.metadata.load = metashare-schema.xml @@ -1411,21 +1412,21 @@ websvc.opensearch.max_num_of_items_per_request = 100 #### Content Inline Disposition Threshold #### # -# Set the max size of a bitstream that can be served inline -# Use -1 to force all bitstream to be served inline -webui.content_disposition_threshold = 8388608 - -#### Content Attachment Disposition Formats #### -# -# Set which mimetypes or file extensions will NOT be opened inline. -# Files with these mimetypes/extensions will always be downloaded, regardless of the threshold above. +# Set which mimetypes or file extensions are allowed to be opened inline in a user's browser. +# By default, all files will be downloaded, regardless of the threshold below, unless specified in this configuration. # NOTE: For security reasons, some file formats (e.g. HTML, XML, RDF, JS) will always be downloaded regardless -# of the settings here. This blocks these formats from executing embedded JavaScript when opened inline. -# For additional security, you may choose to set this to "*" to force all formats to always be downloaded -# (i.e. disables all formats from opening inline within the user's browser). +# of the settings here. This blocks these formats from executing embedded JavaScript when opened inline, protecting +# the site from potential XSS attacks. +# +# For example: this setting defaults to enabling PDF and common image / audio / video formats to be opened +# (or potentially streamed) in a user's browser. +webui.content_disposition_inline = application/pdf, image/gif, image/jpeg, image/png, audio/mpeg, video/mpeg, video/mp4 + # -# By default, RTF is always downloaded because most browsers attempt to display it as plain text. -webui.content_disposition_format = text/richtext +# Set the max size (in bytes) of a bitstream that can be served inline. This setting only applies to formats +# specified in the "webui.content_disposition_inline" configuration above. +# Default = 8MB (8388608 bytes). Use -1 to ignore the size of file when serving it inline. +#webui.content_disposition_threshold = 8388608 #### Multi-file HTML document/site settings ##### # TODO: UNSUPPORTED in DSpace 7.0. May be re-added in a later release @@ -1677,7 +1678,6 @@ include = ${module_dir}/authentication-ldap.cfg include = ${module_dir}/authentication-oidc.cfg include = ${module_dir}/authentication-password.cfg include = ${module_dir}/authentication-shibboleth.cfg -include = ${module_dir}/authentication-x509.cfg include = ${module_dir}/authority.cfg include = ${module_dir}/bulkedit.cfg include = ${module_dir}/citation-page.cfg diff --git a/dspace/config/dstat.map b/dspace/config/dstat.map index 140049ee13a..bfd08ede3c5 100644 --- a/dspace/config/dstat.map +++ b/dspace/config/dstat.map @@ -100,4 +100,8 @@ show_feedback_form=Feedback Form Displayed create_dc_type=New Dublin Core Type Created remove_template_item=Item Template Removed withdraw_item=Item Withdrawn -download_export_archive = Download Export Archive \ No newline at end of file +download_export_archive = Download Export Archive +add_group_eperson = EPerson Added to Group +remove_group_eperson = EPerson Removed from Group +add_group_subgroup = Child Group Added to Group +remove_group_subgroup = Child Group Removed from Group \ No newline at end of file diff --git a/dspace/config/entities/openaire4-relationships.xml b/dspace/config/entities/openaire4-relationships.xml index daa0e2c1da9..d77371b603a 100644 --- a/dspace/config/entities/openaire4-relationships.xml +++ b/dspace/config/entities/openaire4-relationships.xml @@ -3,7 +3,7 @@ - Publication @@ -17,7 +17,7 @@ 0 - Publication @@ -31,7 +31,7 @@ 0 - Publication @@ -45,7 +45,7 @@ 0 - Publication @@ -59,7 +59,7 @@ 0 - Publication @@ -73,7 +73,7 @@ 0 - Project diff --git a/dspace/config/hibernate-ehcache-config.xml b/dspace/config/hibernate-ehcache-config.xml index e2edf67b602..680211a9acd 100644 --- a/dspace/config/hibernate-ehcache-config.xml +++ b/dspace/config/hibernate-ehcache-config.xml @@ -145,4 +145,27 @@ + + + + 1 + + + 500 + + + + + + + 1 + + + 100 + + + diff --git a/dspace/config/local.cfg.EXAMPLE b/dspace/config/local.cfg.EXAMPLE index f0bd363e573..756b25de7c8 100644 --- a/dspace/config/local.cfg.EXAMPLE +++ b/dspace/config/local.cfg.EXAMPLE @@ -213,9 +213,6 @@ db.schema = public # ORCID certificate authentication. # plugin.sequence.org.dspace.authenticate.AuthenticationMethod = org.dspace.authenticate.OrcidAuthentication -# X.509 certificate authentication. See authentication-x509.cfg for default configuration. -#plugin.sequence.org.dspace.authenticate.AuthenticationMethod = org.dspace.authenticate.X509Authentication - # Authentication by Password (encrypted in DSpace's database). See authentication-password.cfg for default configuration. # Enabled by default in authentication.cfg #plugin.sequence.org.dspace.authenticate.AuthenticationMethod = org.dspace.authenticate.PasswordAuthentication diff --git a/dspace/config/migration/item-submissions.xsl b/dspace/config/migration/item-submissions.xsl index 9b1de738e1f..aed95992ab1 100644 --- a/dspace/config/migration/item-submissions.xsl +++ b/dspace/config/migration/item-submissions.xsl @@ -34,35 +34,44 @@ configuration file into a DSpace 7.x (or above) item-submission.xml --> - - - - - - - - - - - + + + + + + + + + + + + org.dspace.app.rest.submit.step.CollectionStep + + + + + + + + + + + + + submission-form + + + - - + + submission + + + submission - - submission-form - - - - - submission - - - submission - - - + + diff --git a/dspace/config/modules/assetstore.cfg b/dspace/config/modules/assetstore.cfg index 57df9959e87..d61e76a0e7a 100644 --- a/dspace/config/modules/assetstore.cfg +++ b/dspace/config/modules/assetstore.cfg @@ -12,12 +12,15 @@ assetstore.dir = ${dspace.dir}/assetstore # This value will be used as `incoming` default store inside the `bitstore.xml` # Possible values are: # - 0: to use the `localStore`; -# - 1: to use the `s3Store`. +# - 1: to use the `s3Store`. # If you want to add additional assetstores, they must be added to that bitstore.xml # and new values should be provided as key-value pairs in the `stores` map of the -# `bitstore.xml` configuration. +# `bitstore.xml` configuration. assetstore.index.primary = 0 +#if the assetstore path is symbolic link, use this configuration to allow that path. +#assetstore.allowed.roots = /data/assetstore + #---------------------------------------------------------------# #-------------- Amazon S3 Specific Configurations --------------# #---------------------------------------------------------------# @@ -44,11 +47,12 @@ assetstore.s3.bucketName = # is shared. Optional, default is root level of bucket assetstore.s3.subfolder = + # please don't use root credentials in production but rely on the aws credentials default # discovery mechanism to configure them (ENV VAR, EC2 Iam role, etc.) # The preferred approach for security reason is to use the IAM user credentials, but isn't always possible. -# More information about credentials here: https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/credentials.html -# More information about IAM usage here: https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/java-dg-roles.html +# More information about credentials here: https://docs.aws.amazon.com/sdk-for-java/v2/developer-guide/credentials.html +# More information about IAM usage here: https://docs.aws.amazon.com/sdk-for-java/v2/developer-guide/credentials-chain.html assetstore.s3.awsAccessKey = assetstore.s3.awsSecretKey = @@ -60,3 +64,17 @@ assetstore.s3.awsRegionName = assetstore.s3.pathStyleAccessEnabled = false # Leave empty to use default (Amazon AWS) endpoint assetstore.s3.endpoint = + +# The target throughput for transfer requests in Gbps. Higher value means more connections will be established with S3. +assetstore.s3.targetThroughputGbps = 10.0 + +# Sets the minimum part size for transfer parts. Decreasing the minimum part size causes multipart transfer to be split +# into a larger number of smaller parts. +assetstore.s3.minPartSizeBytes = 8388608 + +# Specifies the maximum number of S3 connections that should be established during a transfer. +# If not provided, it will be based on targetThroughputGbps +assetstore.s3.maxConcurrency = + +# The algorithm the S3 client will use to create a checksum when doing putObject. +assetstore.s3.s3ChecksumAlgorithm = CRC32 diff --git a/dspace/config/modules/authentication-x509.cfg b/dspace/config/modules/authentication-x509.cfg deleted file mode 100644 index d3f05c7d17d..00000000000 --- a/dspace/config/modules/authentication-x509.cfg +++ /dev/null @@ -1,23 +0,0 @@ -#---------------------------------------------------------------# -#------X.509 CERTIFICATE AUTHENTICATION CONFIGURATIONS----------# -#---------------------------------------------------------------# -# Configuration properties used by the X.509 Certificate # -# Authentication plugin, when it is enabled. # -#---------------------------------------------------------------# - -## method 1, using keystore -#authentication-x509.keystore.path = /tomcat/conf/keystore -#authentication-x509.keystore.password = changeit - -## method 2, using CA certificate -#authentication-x509.ca.cert = ${dspace.dir}/config/MyClientCA.pem - -## Create e-persons for unknown names in valid certificates? -#authentication-x509.autoregister = true - -## Allow Certificate auth to show as a choice in chooser -# Use Messages.properties key for title -#authentication-x509.chooser.title.key=org.dspace.eperson.X509Authentication.title -# -# Identify the location of the Certificate Login Servlet. -#authentication-x509.chooser.uri=/certificate-login diff --git a/dspace/config/modules/authentication.cfg b/dspace/config/modules/authentication.cfg index 568f871e3cd..253035fe3e5 100644 --- a/dspace/config/modules/authentication.cfg +++ b/dspace/config/modules/authentication.cfg @@ -21,9 +21,6 @@ # * IP Address Authentication # Plugin class: org.dspace.authenticate.IPAuthentication # Configuration file: authentication-ip.cfg -# * X.509 Certificate Authentication -# Plugin class: org.dspace.authenticate.X509Authentication -# Configuration file: authentication-x509.cfg # * ORCID certificate authentication. # Plugin class: org.dspace.authenticate.OrcidAuthentication # Configuration file: orcid.cfg @@ -49,9 +46,6 @@ # Shibboleth authentication/authorization. See authentication-shibboleth.cfg for default configuration. #plugin.sequence.org.dspace.authenticate.AuthenticationMethod = org.dspace.authenticate.ShibAuthentication -# X.509 certificate authentication. See authentication-x509.cfg for default configuration. -#plugin.sequence.org.dspace.authenticate.AuthenticationMethod = org.dspace.authenticate.X509Authentication - # ORCID certificate authentication. # plugin.sequence.org.dspace.authenticate.AuthenticationMethod = org.dspace.authenticate.OrcidAuthentication @@ -84,8 +78,9 @@ jwt.login.encryption.enabled = false # of some performance, this setting WILL ONLY BE used when encrypting the jwt. jwt.login.compression.enabled = true -# Expiration time of a token in milliseconds -jwt.login.token.expiration = 1800000 +# Expiration time of a login token in milliseconds +# Default: 1800000 (30 minutes) +#jwt.login.token.expiration = 1800000 #---------------------------------------------------------------# #---Stateless JWT Authentication for downloads of bitstreams----# @@ -109,5 +104,6 @@ jwt.shortLived.encryption.enabled = false # of some performance, this setting WILL ONLY BE used when encrypting the jwt. jwt.shortLived.compression.enabled = true -# Expiration time of a token in milliseconds -jwt.shortLived.token.expiration = 2000 +# Expiration time of a short-lived token in milliseconds +# Default: 2000 (2 seconds) +#jwt.shortLived.token.expiration = 2000 diff --git a/dspace/config/modules/bulkedit.cfg b/dspace/config/modules/bulkedit.cfg index e326e007f88..7a9685d09ac 100644 --- a/dspace/config/modules/bulkedit.cfg +++ b/dspace/config/modules/bulkedit.cfg @@ -14,10 +14,9 @@ # The delimiter used to serarate authority data (defaults to a double colon ::) # bulkedit.authorityseparator = :: -# A hard limit of the number of items allowed to be edited in one go in the UI -# (does not apply to the command line version) -# TODO: UNSUPPORTED in DSpace 7.0 -# bulkedit.gui-item-limit = 20 +# A hard limit on the number of items allowed to be imported via the UI. +# To disable this limit, set this value to 0. Defaults to 1000. +bulkedit.import.max.items = 1000 # Metadata elements to exclude when exporting via the user interfaces, or when using the # command line version and not using the -a (all) option. @@ -45,3 +44,6 @@ bulkedit.change.commit.count = 100 # Recommend to keep this at a feasible number, as exporting large amounts of items can be resource intensive # If not set, this will default to 500 items # bulkedit.export.max.items = 500 + +# Bulkedit setting to add metadata.hide fields to ignored fields defaults to true +# bulkedit.ignore-on-export.include-metadata-hide = true diff --git a/dspace/config/modules/discovery.cfg b/dspace/config/modules/discovery.cfg index cd8e8636c2e..6b3e8316d21 100644 --- a/dspace/config/modules/discovery.cfg +++ b/dspace/config/modules/discovery.cfg @@ -54,3 +54,9 @@ discovery.facet.namedtype.workflow.pooled = 004workflow\n|||\nWaiting for Contro # Set to -1 if stale objects should be ignored. Set to 0 if you want to avoid extra query but take the chance to cleanup # the index each time that stale objects are found. Default 3 discovery.removestale.attempts = 3 + +# Set to true to escape HTML tags in hit highlight results +discovery.highlights.escape-html = true +# Set the fields that should not escape HTML tags in hit highlight results when discovery.highlights.escape-html is true +# It is possible to provide multiple fields by separating them by commas like this: dc.description.abstract, dc.title +# discovery.highlights.html-allowed-fields = diff --git a/dspace/config/modules/external-providers.cfg b/dspace/config/modules/external-providers.cfg index b7c0e120dbc..4f6de6e080e 100644 --- a/dspace/config/modules/external-providers.cfg +++ b/dspace/config/modules/external-providers.cfg @@ -45,6 +45,9 @@ epo.searchUrl = https://ops.epo.org/rest-services/published-data/search ################################################################# #---------------------- PubMed -----------------------------# #---------------------------------------------------------------# +# If apiKey is set then it's used, if not set or blank then it's not +# Max amount of requests per ip per second with apiKey is 10; without 3 +pubmed.apiKey = pubmed.url.search = https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi pubmed.url.fetch = https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi ################################################################# diff --git a/dspace/config/modules/orcid.cfg b/dspace/config/modules/orcid.cfg index 93da0a5f4cb..7eb2af9b808 100644 --- a/dspace/config/modules/orcid.cfg +++ b/dspace/config/modules/orcid.cfg @@ -1,4 +1,3 @@ - #------------------------------------------------------------------# #--------------------ORCID GENERIC CONFIGURATIONS------------------# #------------------------------------------------------------------# @@ -58,12 +57,18 @@ orcid.mapping.work.contributors = dc.contributor.editor::editor ##orcid.mapping.work.external-ids syntax is :: or $simple-handle:: ##The full list of available external identifiers is available here https://pub.orcid.org/v3.0/identifiers +# The identifiers need to have a relationship of SELF, PART_OF, VERSION_OF or FUNDED_BY. +# The default for most identifiers is SELF. The default for identifiers more commonly +# associated with 'parent' publciations (ISSN, ISBN) is PART_OF. +# See the map in `orcid-services.xml` +# VERSION_OF and FUNDED_BY are not currently implemented. orcid.mapping.work.external-ids = dc.identifier.doi::doi orcid.mapping.work.external-ids = dc.identifier.scopus::eid orcid.mapping.work.external-ids = dc.identifier.pmid::pmid orcid.mapping.work.external-ids = $simple-handle::handle orcid.mapping.work.external-ids = dc.identifier.isi::wosuid orcid.mapping.work.external-ids = dc.identifier.issn::issn +orcid.mapping.work.external-ids = dc.identifier.isbn::isbn ### Funding mapping ### orcid.mapping.funding.title = dc.title @@ -141,6 +146,9 @@ orcid.bulk-synchronization.max-attempts = 5 #--------------------ORCID EXTERNAL DATA MAPPING-------------------# #------------------------------------------------------------------# +# Note - the below mapping is for ORCID->DSpace imports, not for +# DSpace->ORCID exports (see orcid.mapping.work.*) + ### Work (Publication) external-data.mapping ### orcid.external-data.mapping.publication.title = dc.title diff --git a/dspace/config/modules/signposting.cfg b/dspace/config/modules/signposting.cfg index 001c55c5359..e0f16cb68d3 100644 --- a/dspace/config/modules/signposting.cfg +++ b/dspace/config/modules/signposting.cfg @@ -32,4 +32,9 @@ signposting.enabled = false signposting.describedby.crosswalk-name = DataCite # Mime-type of response of handling of 'describedby' links. -signposting.describedby.mime-type = application/vnd.datacite.datacite+xml \ No newline at end of file +signposting.describedby.mime-type = application/vnd.datacite.datacite+xml + +# Limit to the number of an item's bitstreams to return as typed links. +# If there are more bitstreams than this limit then only the typed links to the Link Sets are added to the header. +# Defaults to 10 if the value is unspecified +# signposting.item.bitstreams.limit = 10 \ No newline at end of file diff --git a/dspace/config/modules/usage-statistics.cfg b/dspace/config/modules/usage-statistics.cfg index c77bb1ca78a..6d47a13dcfc 100644 --- a/dspace/config/modules/usage-statistics.cfg +++ b/dspace/config/modules/usage-statistics.cfg @@ -60,4 +60,21 @@ usage-statistics.shardedByYear = false #anonymize_statistics.dns_mask = anonymized # Only anonymize statistics records older than this threshold (expressed in days) -#anonymize_statistics.time_threshold = 90 \ No newline at end of file +#anonymize_statistics.time_threshold = 90 + +# Maximum number of items to display in the usage statistics report for an entire repository +usage-statistics.topItemsLimit = 10 + +# Number of months to begin retrieving usage statistics for total visits per month of a DSpace object +# For example, -6 means include the previous six months +usage-statistics.startDateInterval = -6 + +# Number of months to end retrieving usage statistics for total visits per month of a DSpace object +# For example, +1 means include the current month +usage-statistics.endDateInterval = +1 + +# Maximum number of countries to display in the usage statistics reports +usage-statistics.topCountriesLimit = 100 + +# Maximum number of cities to display in the usage statistics reports +usage-statistics.topCitiesLimit = 100 diff --git a/dspace/config/registries/journal-types.xml b/dspace/config/registries/journal-types.xml new file mode 100644 index 00000000000..c68bee1ce6c --- /dev/null +++ b/dspace/config/registries/journal-types.xml @@ -0,0 +1,37 @@ + + + + + + DSpace Journal Types + + + + + + journal + http://dspace.org/journal + + + + journal + title + + The title of the Journal related to this object + + + + + journalvolume + http://dspace.org/journalvolume + + + + journalvolume + identifier + name + The identifier name for the Journal Volume related to this object + + + diff --git a/dspace/config/registries/openaire4-types.xml b/dspace/config/registries/openaire4-types.xml index b3290ac1203..749b13fa315 100644 --- a/dspace/config/registries/openaire4-types.xml +++ b/dspace/config/registries/openaire4-types.xml @@ -2,13 +2,13 @@ - OpenAIRE4 fields definition + OpenAIRE v4 fields definition @@ -108,7 +108,7 @@ datacite @@ -116,7 +116,7 @@ Spatial region or named place where the data was gathered or about which the data is focused. - + datacite subject diff --git a/dspace/config/registries/schema-person-types.xml b/dspace/config/registries/schema-person-types.xml index 3a8f79732d4..9a3c494de49 100644 --- a/dspace/config/registries/schema-person-types.xml +++ b/dspace/config/registries/schema-person-types.xml @@ -156,4 +156,13 @@ Full name variant + + + person + contributor + other + + + \ No newline at end of file diff --git a/dspace/config/spring/api/bitstore.xml b/dspace/config/spring/api/bitstore.xml index c208e27e71f..9cd0dad6d2f 100644 --- a/dspace/config/spring/api/bitstore.xml +++ b/dspace/config/spring/api/bitstore.xml @@ -36,8 +36,24 @@ - + + + + + + + + + + + + + diff --git a/dspace/config/spring/api/core-services.xml b/dspace/config/spring/api/core-services.xml index 9017ea7fae7..0d440ad1967 100644 --- a/dspace/config/spring/api/core-services.xml +++ b/dspace/config/spring/api/core-services.xml @@ -32,7 +32,7 @@ - + diff --git a/dspace/config/spring/api/discovery.xml b/dspace/config/spring/api/discovery.xml index 6ffc439278f..59e01c0775d 100644 --- a/dspace/config/spring/api/discovery.xml +++ b/dspace/config/spring/api/discovery.xml @@ -30,8 +30,6 @@ - - diff --git a/dspace/config/spring/api/orcid-services.xml b/dspace/config/spring/api/orcid-services.xml index eb31acb29c4..a2f914d0ad2 100644 --- a/dspace/config/spring/api/orcid-services.xml +++ b/dspace/config/spring/api/orcid-services.xml @@ -55,24 +55,45 @@ - - - - - - - - - - - - - - - - - - + + + + + journal-article + magazine-article + newspaper-article + data-set + learning-object + other + + + + + book-chapter + book-review + other + + + + + + + + + + + + + + + + + + + + + + diff --git a/dspace/config/spring/api/workflow-actions.xml b/dspace/config/spring/api/workflow-actions.xml index d01f1b6b4c8..66d3a54e367 100644 --- a/dspace/config/spring/api/workflow-actions.xml +++ b/dspace/config/spring/api/workflow-actions.xml @@ -21,7 +21,6 @@ - @@ -44,7 +43,6 @@ - @@ -64,21 +62,14 @@ - - - - - - - + - - + diff --git a/dspace/config/submission-forms.xml b/dspace/config/submission-forms.xml index 0e70467e95b..5032aa49428 100644 --- a/dspace/config/submission-forms.xml +++ b/dspace/config/submission-forms.xml @@ -39,7 +39,7 @@ dc description - true + false textarea Enter a description for the file @@ -2309,7 +2309,7 @@ diff --git a/dspace/modules/additions/pom.xml b/dspace/modules/additions/pom.xml index 125f0e16cce..adcf996f984 100644 --- a/dspace/modules/additions/pom.xml +++ b/dspace/modules/additions/pom.xml @@ -17,7 +17,7 @@ org.dspace modules - 7.6.5 + 7.6.7 .. diff --git a/dspace/modules/additions/src/main/resources/mime.types b/dspace/modules/additions/src/main/resources/mime.types new file mode 100644 index 00000000000..6707e7e794f --- /dev/null +++ b/dspace/modules/additions/src/main/resources/mime.types @@ -0,0 +1,1863 @@ +# The mime.types file comes from the iiif-apis library https://github.com/dbmdz/iiif-apis/blob/main/src/main/resources/mime.types. +# The mime.types file is protected by the MIT license defined here https://github.com/dbmdz/iiif-apis/blob/main/LICENSE. + +# Due to this issue https://github.com/dbmdz/iiif-apis/issues/270 +# The mime.types file has been added to the DSpace sources. +# The mime.types file can be removed as soon as the ticket is closed. + + +# This file maps Internet media types to unique file extension(s). +# Although created for httpd, this file is used by many software systems +# and has been placed in the public domain for unlimited redisribution. +# +# The table below contains both registered and (common) unregistered types. +# A type that has no unique extension can be ignored -- they are listed +# here to guide configurations toward known types and to make it easier to +# identify "new" types. File extensions are also commonly used to indicate +# content languages and encodings, so choose them carefully. +# +# Internet media types should be registered as described in RFC 4288. +# The registry is at . +# +# MIME type (lowercased) Extensions +# ============================================ ========== +# application/1d-interleaved-parityfec +# application/3gpdash-qoe-report+xml +# application/3gpp-ims+xml +# application/a2l +# application/activemessage +# application/alto-costmap+json +# application/alto-costmapfilter+json +# application/alto-directory+json +# application/alto-endpointcost+json +# application/alto-endpointcostparams+json +# application/alto-endpointprop+json +# application/alto-endpointpropparams+json +# application/alto-error+json +# application/alto-networkmap+json +# application/alto-networkmapfilter+json +# application/aml +application/andrew-inset ez +# application/applefile +application/applixware aw +# application/atf +# application/atfx +application/atom+xml atom +application/atomcat+xml atomcat +# application/atomdeleted+xml +# application/atomicmail +application/atomsvc+xml atomsvc +# application/atxml +# application/auth-policy+xml +# application/bacnet-xdd+zip +# application/batch-smtp +# application/beep+xml +# application/calendar+json +# application/calendar+xml +# application/call-completion +# application/cals-1840 +# application/cbor +# application/ccmp+xml +application/ccxml+xml ccxml +# application/cdfx+xml +application/cdmi-capability cdmia +application/cdmi-container cdmic +application/cdmi-domain cdmid +application/cdmi-object cdmio +application/cdmi-queue cdmiq +# application/cdni +# application/cea +# application/cea-2018+xml +# application/cellml+xml +# application/cfw +# application/cms +# application/cnrp+xml +# application/coap-group+json +# application/commonground +# application/conference-info+xml +# application/cpl+xml +# application/csrattrs +# application/csta+xml +# application/cstadata+xml +# application/csvm+json +application/cu-seeme cu +# application/cybercash +# application/dash+xml +# application/dashdelta +application/davmount+xml davmount +# application/dca-rft +# application/dcd +# application/dec-dx +# application/dialog-info+xml +# application/dicom +# application/dii +# application/dit +# application/dns +application/docbook+xml dbk +# application/dskpp+xml +application/dssc+der dssc +application/dssc+xml xdssc +# application/dvcs +application/ecmascript ecma +# application/edi-consent +# application/edi-x12 +# application/edifact +# application/efi +# application/emergencycalldata.comment+xml +# application/emergencycalldata.deviceinfo+xml +# application/emergencycalldata.providerinfo+xml +# application/emergencycalldata.serviceinfo+xml +# application/emergencycalldata.subscriberinfo+xml +application/emma+xml emma +# application/emotionml+xml +# application/encaprtp +# application/epp+xml +application/epub+zip epub +# application/eshop +# application/example +application/exi exi +# application/fastinfoset +# application/fastsoap +# application/fdt+xml +# application/fits +application/font-tdpfr pfr +# application/framework-attributes+xml +# application/geo+json +application/gml+xml gml +application/gpx+xml gpx +application/gxf gxf +# application/gzip +# application/h224 +# application/held+xml +# application/http +application/hyperstudio stk +# application/ibe-key-request+xml +# application/ibe-pkg-reply+xml +# application/ibe-pp-data +# application/iges +# application/im-iscomposing+xml +# application/index +# application/index.cmd +# application/index.obj +# application/index.response +# application/index.vnd +application/inkml+xml ink inkml +# application/iotp +application/ipfix ipfix +# application/ipp +# application/isup +# application/its+xml +application/java-archive jar +application/java-serialized-object ser +application/java-vm class +application/javascript js +# application/jose +# application/jose+json +# application/jrd+json +application/json json +# application/json-patch+json +# application/json-seq +application/jsonml+json jsonml +# application/jwk+json +# application/jwk-set+json +# application/jwt +# application/kpml-request+xml +# application/kpml-response+xml +# application/ld+json +# application/lgr+xml +# application/link-format +# application/load-control+xml +application/lost+xml lostxml +# application/lostsync+xml +# application/lxf +application/mac-binhex40 hqx +application/mac-compactpro cpt +# application/macwriteii +application/mads+xml mads +application/marc mrc +application/marcxml+xml mrcx +application/mathematica ma nb mb +application/mathml+xml mathml +# application/mathml-content+xml +# application/mathml-presentation+xml +# application/mbms-associated-procedure-description+xml +# application/mbms-deregister+xml +# application/mbms-envelope+xml +# application/mbms-msk+xml +# application/mbms-msk-response+xml +# application/mbms-protection-description+xml +# application/mbms-reception-report+xml +# application/mbms-register+xml +# application/mbms-register-response+xml +# application/mbms-schedule+xml +# application/mbms-user-service-description+xml +application/mbox mbox +# application/media-policy-dataset+xml +# application/media_control+xml +application/mediaservercontrol+xml mscml +# application/merge-patch+json +application/metalink+xml metalink +application/metalink4+xml meta4 +application/mets+xml mets +# application/mf4 +# application/mikey +application/mods+xml mods +# application/moss-keys +# application/moss-signature +# application/mosskey-data +# application/mosskey-request +application/mp21 m21 mp21 +application/mp4 mp4s +# application/mpeg4-generic +# application/mpeg4-iod +# application/mpeg4-iod-xmt +# application/mrb-consumer+xml +# application/mrb-publish+xml +# application/msc-ivr+xml +# application/msc-mixer+xml +application/msword doc dot +application/mxf mxf +# application/nasdata +# application/news-checkgroups +# application/news-groupinfo +# application/news-transmission +# application/nlsml+xml +# application/nss +# application/ocsp-request +# application/ocsp-response +application/octet-stream bin dms lrf mar so dist distz pkg bpk dump elc deploy +application/oda oda +# application/odx +application/oebps-package+xml opf +application/ogg ogx +application/omdoc+xml omdoc +application/onenote onetoc onetoc2 onetmp onepkg +application/oxps oxps +# application/p2p-overlay+xml +# application/parityfec +application/patch-ops-error+xml xer +application/pdf pdf +# application/pdx +application/pgp-encrypted pgp +# application/pgp-keys +application/pgp-signature asc sig +application/pics-rules prf +# application/pidf+xml +# application/pidf-diff+xml +application/pkcs10 p10 +# application/pkcs12 +application/pkcs7-mime p7m p7c +application/pkcs7-signature p7s +application/pkcs8 p8 +application/pkix-attr-cert ac +application/pkix-cert cer +application/pkix-crl crl +application/pkix-pkipath pkipath +application/pkixcmp pki +application/pls+xml pls +# application/poc-settings+xml +application/postscript ai eps ps +# application/ppsp-tracker+json +# application/problem+json +# application/problem+xml +# application/provenance+xml +# application/prs.alvestrand.titrax-sheet +application/prs.cww cww +# application/prs.hpub+zip +# application/prs.nprend +# application/prs.plucker +# application/prs.rdf-xml-crypt +# application/prs.xsf+xml +application/pskc+xml pskcxml +# application/qsig +# application/raptorfec +# application/rdap+json +application/rdf+xml rdf +application/reginfo+xml rif +application/relax-ng-compact-syntax rnc +# application/remote-printing +# application/reputon+json +application/resource-lists+xml rl +application/resource-lists-diff+xml rld +# application/rfc+xml +# application/riscos +# application/rlmi+xml +application/rls-services+xml rs +application/rpki-ghostbusters gbr +application/rpki-manifest mft +application/rpki-roa roa +# application/rpki-updown +application/rsd+xml rsd +application/rss+xml rss +application/rtf rtf +# application/rtploopback +# application/rtx +# application/samlassertion+xml +# application/samlmetadata+xml +application/sbml+xml sbml +# application/scaip+xml +# application/scim+json +application/scvp-cv-request scq +application/scvp-cv-response scs +application/scvp-vp-request spq +application/scvp-vp-response spp +application/sdp sdp +# application/sep+xml +# application/sep-exi +# application/session-info +# application/set-payment +application/set-payment-initiation setpay +# application/set-registration +application/set-registration-initiation setreg +# application/sgml +# application/sgml-open-catalog +application/shf+xml shf +# application/sieve +# application/simple-filter+xml +# application/simple-message-summary +# application/simplesymbolcontainer +# application/slate +# application/smil +application/smil+xml smi smil +# application/smpte336m +# application/soap+fastinfoset +# application/soap+xml +application/sparql-query rq +application/sparql-results+xml srx +# application/spirits-event+xml +# application/sql +application/srgs gram +application/srgs+xml grxml +application/sru+xml sru +application/ssdl+xml ssdl +application/ssml+xml ssml +# application/tamp-apex-update +# application/tamp-apex-update-confirm +# application/tamp-community-update +# application/tamp-community-update-confirm +# application/tamp-error +# application/tamp-sequence-adjust +# application/tamp-sequence-adjust-confirm +# application/tamp-status-query +# application/tamp-status-response +# application/tamp-update +# application/tamp-update-confirm +application/tei+xml tei teicorpus +application/thraud+xml tfi +# application/timestamp-query +# application/timestamp-reply +application/timestamped-data tsd +# application/ttml+xml +# application/tve-trigger +# application/ulpfec +# application/urc-grpsheet+xml +# application/urc-ressheet+xml +# application/urc-targetdesc+xml +# application/urc-uisocketdesc+xml +# application/vcard+json +# application/vcard+xml +# application/vemmi +# application/vividence.scriptfile +# application/vnd.3gpp-prose+xml +# application/vnd.3gpp-prose-pc3ch+xml +# application/vnd.3gpp.access-transfer-events+xml +# application/vnd.3gpp.bsf+xml +# application/vnd.3gpp.mid-call+xml +application/vnd.3gpp.pic-bw-large plb +application/vnd.3gpp.pic-bw-small psb +application/vnd.3gpp.pic-bw-var pvb +# application/vnd.3gpp.sms +# application/vnd.3gpp.sms+xml +# application/vnd.3gpp.srvcc-ext+xml +# application/vnd.3gpp.srvcc-info+xml +# application/vnd.3gpp.state-and-event-info+xml +# application/vnd.3gpp.ussd+xml +# application/vnd.3gpp2.bcmcsinfo+xml +# application/vnd.3gpp2.sms +application/vnd.3gpp2.tcap tcap +# application/vnd.3lightssoftware.imagescal +application/vnd.3m.post-it-notes pwn +application/vnd.accpac.simply.aso aso +application/vnd.accpac.simply.imp imp +application/vnd.acucobol acu +application/vnd.acucorp atc acutc +application/vnd.adobe.air-application-installer-package+zip air +# application/vnd.adobe.flash.movie +application/vnd.adobe.formscentral.fcdt fcdt +application/vnd.adobe.fxp fxp fxpl +# application/vnd.adobe.partial-upload +application/vnd.adobe.xdp+xml xdp +application/vnd.adobe.xfdf xfdf +# application/vnd.aether.imp +# application/vnd.ah-barcode +application/vnd.ahead.space ahead +application/vnd.airzip.filesecure.azf azf +application/vnd.airzip.filesecure.azs azs +application/vnd.amazon.ebook azw +# application/vnd.amazon.mobi8-ebook +application/vnd.americandynamics.acc acc +application/vnd.amiga.ami ami +# application/vnd.amundsen.maze+xml +application/vnd.android.package-archive apk +# application/vnd.anki +application/vnd.anser-web-certificate-issue-initiation cii +application/vnd.anser-web-funds-transfer-initiation fti +application/vnd.antix.game-component atx +# application/vnd.apache.thrift.binary +# application/vnd.apache.thrift.compact +# application/vnd.apache.thrift.json +# application/vnd.api+json +application/vnd.apple.installer+xml mpkg +application/vnd.apple.mpegurl m3u8 +# application/vnd.arastra.swi +application/vnd.aristanetworks.swi swi +# application/vnd.artsquare +application/vnd.astraea-software.iota iota +application/vnd.audiograph aep +# application/vnd.autopackage +# application/vnd.avistar+xml +# application/vnd.balsamiq.bmml+xml +# application/vnd.balsamiq.bmpr +# application/vnd.bekitzur-stech+json +# application/vnd.biopax.rdf+xml +application/vnd.blueice.multipass mpm +# application/vnd.bluetooth.ep.oob +# application/vnd.bluetooth.le.oob +application/vnd.bmi bmi +application/vnd.businessobjects rep +# application/vnd.cab-jscript +# application/vnd.canon-cpdl +# application/vnd.canon-lips +# application/vnd.cendio.thinlinc.clientconf +# application/vnd.century-systems.tcp_stream +application/vnd.chemdraw+xml cdxml +# application/vnd.chess-pgn +application/vnd.chipnuts.karaoke-mmd mmd +application/vnd.cinderella cdy +# application/vnd.cirpack.isdn-ext +# application/vnd.citationstyles.style+xml +application/vnd.claymore cla +application/vnd.cloanto.rp9 rp9 +application/vnd.clonk.c4group c4g c4d c4f c4p c4u +application/vnd.cluetrust.cartomobile-config c11amc +application/vnd.cluetrust.cartomobile-config-pkg c11amz +# application/vnd.coffeescript +# application/vnd.collection+json +# application/vnd.collection.doc+json +# application/vnd.collection.next+json +# application/vnd.comicbook+zip +# application/vnd.commerce-battelle +application/vnd.commonspace csp +application/vnd.contact.cmsg cdbcmsg +# application/vnd.coreos.ignition+json +application/vnd.cosmocaller cmc +application/vnd.crick.clicker clkx +application/vnd.crick.clicker.keyboard clkk +application/vnd.crick.clicker.palette clkp +application/vnd.crick.clicker.template clkt +application/vnd.crick.clicker.wordbank clkw +application/vnd.criticaltools.wbs+xml wbs +application/vnd.ctc-posml pml +# application/vnd.ctct.ws+xml +# application/vnd.cups-pdf +# application/vnd.cups-postscript +application/vnd.cups-ppd ppd +# application/vnd.cups-raster +# application/vnd.cups-raw +# application/vnd.curl +application/vnd.curl.car car +application/vnd.curl.pcurl pcurl +# application/vnd.cyan.dean.root+xml +# application/vnd.cybank +application/vnd.dart dart +application/vnd.data-vision.rdz rdz +# application/vnd.debian.binary-package +application/vnd.dece.data uvf uvvf uvd uvvd +application/vnd.dece.ttml+xml uvt uvvt +application/vnd.dece.unspecified uvx uvvx +application/vnd.dece.zip uvz uvvz +application/vnd.denovo.fcselayout-link fe_launch +# application/vnd.desmume.movie +# application/vnd.dir-bi.plate-dl-nosuffix +# application/vnd.dm.delegation+xml +application/vnd.dna dna +# application/vnd.document+json +application/vnd.dolby.mlp mlp +# application/vnd.dolby.mobile.1 +# application/vnd.dolby.mobile.2 +# application/vnd.doremir.scorecloud-binary-document +application/vnd.dpgraph dpg +application/vnd.dreamfactory dfac +# application/vnd.drive+json +application/vnd.ds-keypoint kpxx +# application/vnd.dtg.local +# application/vnd.dtg.local.flash +# application/vnd.dtg.local.html +application/vnd.dvb.ait ait +# application/vnd.dvb.dvbj +# application/vnd.dvb.esgcontainer +# application/vnd.dvb.ipdcdftnotifaccess +# application/vnd.dvb.ipdcesgaccess +# application/vnd.dvb.ipdcesgaccess2 +# application/vnd.dvb.ipdcesgpdd +# application/vnd.dvb.ipdcroaming +# application/vnd.dvb.iptv.alfec-base +# application/vnd.dvb.iptv.alfec-enhancement +# application/vnd.dvb.notif-aggregate-root+xml +# application/vnd.dvb.notif-container+xml +# application/vnd.dvb.notif-generic+xml +# application/vnd.dvb.notif-ia-msglist+xml +# application/vnd.dvb.notif-ia-registration-request+xml +# application/vnd.dvb.notif-ia-registration-response+xml +# application/vnd.dvb.notif-init+xml +# application/vnd.dvb.pfr +application/vnd.dvb.service svc +# application/vnd.dxr +application/vnd.dynageo geo +# application/vnd.dzr +# application/vnd.easykaraoke.cdgdownload +# application/vnd.ecdis-update +application/vnd.ecowin.chart mag +# application/vnd.ecowin.filerequest +# application/vnd.ecowin.fileupdate +# application/vnd.ecowin.series +# application/vnd.ecowin.seriesrequest +# application/vnd.ecowin.seriesupdate +# application/vnd.emclient.accessrequest+xml +application/vnd.enliven nml +# application/vnd.enphase.envoy +# application/vnd.eprints.data+xml +application/vnd.epson.esf esf +application/vnd.epson.msf msf +application/vnd.epson.quickanime qam +application/vnd.epson.salt slt +application/vnd.epson.ssf ssf +# application/vnd.ericsson.quickcall +application/vnd.eszigno3+xml es3 et3 +# application/vnd.etsi.aoc+xml +# application/vnd.etsi.asic-e+zip +# application/vnd.etsi.asic-s+zip +# application/vnd.etsi.cug+xml +# application/vnd.etsi.iptvcommand+xml +# application/vnd.etsi.iptvdiscovery+xml +# application/vnd.etsi.iptvprofile+xml +# application/vnd.etsi.iptvsad-bc+xml +# application/vnd.etsi.iptvsad-cod+xml +# application/vnd.etsi.iptvsad-npvr+xml +# application/vnd.etsi.iptvservice+xml +# application/vnd.etsi.iptvsync+xml +# application/vnd.etsi.iptvueprofile+xml +# application/vnd.etsi.mcid+xml +# application/vnd.etsi.mheg5 +# application/vnd.etsi.overload-control-policy-dataset+xml +# application/vnd.etsi.pstn+xml +# application/vnd.etsi.sci+xml +# application/vnd.etsi.simservs+xml +# application/vnd.etsi.timestamp-token +# application/vnd.etsi.tsl+xml +# application/vnd.etsi.tsl.der +# application/vnd.eudora.data +application/vnd.ezpix-album ez2 +application/vnd.ezpix-package ez3 +# application/vnd.f-secure.mobile +# application/vnd.fastcopy-disk-image +application/vnd.fdf fdf +application/vnd.fdsn.mseed mseed +application/vnd.fdsn.seed seed dataless +# application/vnd.ffsns +# application/vnd.filmit.zfc +# application/vnd.fints +# application/vnd.firemonkeys.cloudcell +application/vnd.flographit gph +application/vnd.fluxtime.clip ftc +# application/vnd.font-fontforge-sfd +application/vnd.framemaker fm frame maker book +application/vnd.frogans.fnc fnc +application/vnd.frogans.ltf ltf +application/vnd.fsc.weblaunch fsc +application/vnd.fujitsu.oasys oas +application/vnd.fujitsu.oasys2 oa2 +application/vnd.fujitsu.oasys3 oa3 +application/vnd.fujitsu.oasysgp fg5 +application/vnd.fujitsu.oasysprs bh2 +# application/vnd.fujixerox.art-ex +# application/vnd.fujixerox.art4 +application/vnd.fujixerox.ddd ddd +application/vnd.fujixerox.docuworks xdw +application/vnd.fujixerox.docuworks.binder xbd +# application/vnd.fujixerox.docuworks.container +# application/vnd.fujixerox.hbpl +# application/vnd.fut-misnet +application/vnd.fuzzysheet fzs +application/vnd.genomatix.tuxedo txd +# application/vnd.geo+json +# application/vnd.geocube+xml +application/vnd.geogebra.file ggb +application/vnd.geogebra.tool ggt +application/vnd.geometry-explorer gex gre +application/vnd.geonext gxt +application/vnd.geoplan g2w +application/vnd.geospace g3w +# application/vnd.gerber +# application/vnd.globalplatform.card-content-mgt +# application/vnd.globalplatform.card-content-mgt-response +application/vnd.gmx gmx +application/vnd.google-earth.kml+xml kml +application/vnd.google-earth.kmz kmz +# application/vnd.gov.sk.e-form+xml +# application/vnd.gov.sk.e-form+zip +# application/vnd.gov.sk.xmldatacontainer+xml +application/vnd.grafeq gqf gqs +# application/vnd.gridmp +application/vnd.groove-account gac +application/vnd.groove-help ghf +application/vnd.groove-identity-message gim +application/vnd.groove-injector grv +application/vnd.groove-tool-message gtm +application/vnd.groove-tool-template tpl +application/vnd.groove-vcard vcg +# application/vnd.hal+json +application/vnd.hal+xml hal +application/vnd.handheld-entertainment+xml zmm +application/vnd.hbci hbci +# application/vnd.hcl-bireports +# application/vnd.hdt +# application/vnd.heroku+json +application/vnd.hhe.lesson-player les +application/vnd.hp-hpgl hpgl +application/vnd.hp-hpid hpid +application/vnd.hp-hps hps +application/vnd.hp-jlyt jlt +application/vnd.hp-pcl pcl +application/vnd.hp-pclxl pclxl +# application/vnd.httphone +application/vnd.hydrostatix.sof-data sfd-hdstx +# application/vnd.hyperdrive+json +# application/vnd.hzn-3d-crossword +# application/vnd.ibm.afplinedata +# application/vnd.ibm.electronic-media +application/vnd.ibm.minipay mpy +application/vnd.ibm.modcap afp listafp list3820 +application/vnd.ibm.rights-management irm +application/vnd.ibm.secure-container sc +application/vnd.iccprofile icc icm +# application/vnd.ieee.1905 +application/vnd.igloader igl +application/vnd.immervision-ivp ivp +application/vnd.immervision-ivu ivu +# application/vnd.ims.imsccv1p1 +# application/vnd.ims.imsccv1p2 +# application/vnd.ims.imsccv1p3 +# application/vnd.ims.lis.v2.result+json +# application/vnd.ims.lti.v2.toolconsumerprofile+json +# application/vnd.ims.lti.v2.toolproxy+json +# application/vnd.ims.lti.v2.toolproxy.id+json +# application/vnd.ims.lti.v2.toolsettings+json +# application/vnd.ims.lti.v2.toolsettings.simple+json +# application/vnd.informedcontrol.rms+xml +# application/vnd.informix-visionary +# application/vnd.infotech.project +# application/vnd.infotech.project+xml +# application/vnd.innopath.wamp.notification +application/vnd.insors.igm igm +application/vnd.intercon.formnet xpw xpx +application/vnd.intergeo i2g +# application/vnd.intertrust.digibox +# application/vnd.intertrust.nncp +application/vnd.intu.qbo qbo +application/vnd.intu.qfx qfx +# application/vnd.iptc.g2.catalogitem+xml +# application/vnd.iptc.g2.conceptitem+xml +# application/vnd.iptc.g2.knowledgeitem+xml +# application/vnd.iptc.g2.newsitem+xml +# application/vnd.iptc.g2.newsmessage+xml +# application/vnd.iptc.g2.packageitem+xml +# application/vnd.iptc.g2.planningitem+xml +application/vnd.ipunplugged.rcprofile rcprofile +application/vnd.irepository.package+xml irp +application/vnd.is-xpr xpr +application/vnd.isac.fcs fcs +application/vnd.jam jam +# application/vnd.japannet-directory-service +# application/vnd.japannet-jpnstore-wakeup +# application/vnd.japannet-payment-wakeup +# application/vnd.japannet-registration +# application/vnd.japannet-registration-wakeup +# application/vnd.japannet-setstore-wakeup +# application/vnd.japannet-verification +# application/vnd.japannet-verification-wakeup +application/vnd.jcp.javame.midlet-rms rms +application/vnd.jisp jisp +application/vnd.joost.joda-archive joda +# application/vnd.jsk.isdn-ngn +application/vnd.kahootz ktz ktr +application/vnd.kde.karbon karbon +application/vnd.kde.kchart chrt +application/vnd.kde.kformula kfo +application/vnd.kde.kivio flw +application/vnd.kde.kontour kon +application/vnd.kde.kpresenter kpr kpt +application/vnd.kde.kspread ksp +application/vnd.kde.kword kwd kwt +application/vnd.kenameaapp htke +application/vnd.kidspiration kia +application/vnd.kinar kne knp +application/vnd.koan skp skd skt skm +application/vnd.kodak-descriptor sse +application/vnd.las.las+xml lasxml +# application/vnd.liberty-request+xml +application/vnd.llamagraphics.life-balance.desktop lbd +application/vnd.llamagraphics.life-balance.exchange+xml lbe +application/vnd.lotus-1-2-3 123 +application/vnd.lotus-approach apr +application/vnd.lotus-freelance pre +application/vnd.lotus-notes nsf +application/vnd.lotus-organizer org +application/vnd.lotus-screencam scm +application/vnd.lotus-wordpro lwp +application/vnd.macports.portpkg portpkg +# application/vnd.mapbox-vector-tile +# application/vnd.marlin.drm.actiontoken+xml +# application/vnd.marlin.drm.conftoken+xml +# application/vnd.marlin.drm.license+xml +# application/vnd.marlin.drm.mdcf +# application/vnd.mason+json +# application/vnd.maxmind.maxmind-db +application/vnd.mcd mcd +application/vnd.medcalcdata mc1 +application/vnd.mediastation.cdkey cdkey +# application/vnd.meridian-slingshot +application/vnd.mfer mwf +application/vnd.mfmp mfm +# application/vnd.micro+json +application/vnd.micrografx.flo flo +application/vnd.micrografx.igx igx +# application/vnd.microsoft.portable-executable +# application/vnd.miele+json +application/vnd.mif mif +# application/vnd.minisoft-hp3000-save +# application/vnd.mitsubishi.misty-guard.trustweb +application/vnd.mobius.daf daf +application/vnd.mobius.dis dis +application/vnd.mobius.mbk mbk +application/vnd.mobius.mqy mqy +application/vnd.mobius.msl msl +application/vnd.mobius.plc plc +application/vnd.mobius.txf txf +application/vnd.mophun.application mpn +application/vnd.mophun.certificate mpc +# application/vnd.motorola.flexsuite +# application/vnd.motorola.flexsuite.adsi +# application/vnd.motorola.flexsuite.fis +# application/vnd.motorola.flexsuite.gotap +# application/vnd.motorola.flexsuite.kmr +# application/vnd.motorola.flexsuite.ttc +# application/vnd.motorola.flexsuite.wem +# application/vnd.motorola.iprm +application/vnd.mozilla.xul+xml xul +# application/vnd.ms-3mfdocument +application/vnd.ms-artgalry cil +# application/vnd.ms-asf +application/vnd.ms-cab-compressed cab +# application/vnd.ms-color.iccprofile +application/vnd.ms-excel xls xlm xla xlc xlt xlw +application/vnd.ms-excel.addin.macroenabled.12 xlam +application/vnd.ms-excel.sheet.binary.macroenabled.12 xlsb +application/vnd.ms-excel.sheet.macroenabled.12 xlsm +application/vnd.ms-excel.template.macroenabled.12 xltm +application/vnd.ms-fontobject eot +application/vnd.ms-htmlhelp chm +application/vnd.ms-ims ims +application/vnd.ms-lrm lrm +# application/vnd.ms-office.activex+xml +application/vnd.ms-officetheme thmx +# application/vnd.ms-opentype +# application/vnd.ms-package.obfuscated-opentype +application/vnd.ms-pki.seccat cat +application/vnd.ms-pki.stl stl +# application/vnd.ms-playready.initiator+xml +application/vnd.ms-powerpoint ppt pps pot +application/vnd.ms-powerpoint.addin.macroenabled.12 ppam +application/vnd.ms-powerpoint.presentation.macroenabled.12 pptm +application/vnd.ms-powerpoint.slide.macroenabled.12 sldm +application/vnd.ms-powerpoint.slideshow.macroenabled.12 ppsm +application/vnd.ms-powerpoint.template.macroenabled.12 potm +# application/vnd.ms-printdevicecapabilities+xml +# application/vnd.ms-printing.printticket+xml +# application/vnd.ms-printschematicket+xml +application/vnd.ms-project mpp mpt +# application/vnd.ms-tnef +# application/vnd.ms-windows.devicepairing +# application/vnd.ms-windows.nwprinting.oob +# application/vnd.ms-windows.printerpairing +# application/vnd.ms-windows.wsd.oob +# application/vnd.ms-wmdrm.lic-chlg-req +# application/vnd.ms-wmdrm.lic-resp +# application/vnd.ms-wmdrm.meter-chlg-req +# application/vnd.ms-wmdrm.meter-resp +application/vnd.ms-word.document.macroenabled.12 docm +application/vnd.ms-word.template.macroenabled.12 dotm +application/vnd.ms-works wps wks wcm wdb +application/vnd.ms-wpl wpl +application/vnd.ms-xpsdocument xps +# application/vnd.msa-disk-image +application/vnd.mseq mseq +# application/vnd.msign +# application/vnd.multiad.creator +# application/vnd.multiad.creator.cif +# application/vnd.music-niff +application/vnd.musician mus +application/vnd.muvee.style msty +application/vnd.mynfc taglet +# application/vnd.ncd.control +# application/vnd.ncd.reference +# application/vnd.nervana +# application/vnd.netfpx +application/vnd.neurolanguage.nlu nlu +# application/vnd.nintendo.nitro.rom +# application/vnd.nintendo.snes.rom +application/vnd.nitf ntf nitf +application/vnd.noblenet-directory nnd +application/vnd.noblenet-sealer nns +application/vnd.noblenet-web nnw +# application/vnd.nokia.catalogs +# application/vnd.nokia.conml+wbxml +# application/vnd.nokia.conml+xml +# application/vnd.nokia.iptv.config+xml +# application/vnd.nokia.isds-radio-presets +# application/vnd.nokia.landmark+wbxml +# application/vnd.nokia.landmark+xml +# application/vnd.nokia.landmarkcollection+xml +# application/vnd.nokia.n-gage.ac+xml +application/vnd.nokia.n-gage.data ngdat +application/vnd.nokia.n-gage.symbian.install n-gage +# application/vnd.nokia.ncd +# application/vnd.nokia.pcd+wbxml +# application/vnd.nokia.pcd+xml +application/vnd.nokia.radio-preset rpst +application/vnd.nokia.radio-presets rpss +application/vnd.novadigm.edm edm +application/vnd.novadigm.edx edx +application/vnd.novadigm.ext ext +# application/vnd.ntt-local.content-share +# application/vnd.ntt-local.file-transfer +# application/vnd.ntt-local.ogw_remote-access +# application/vnd.ntt-local.sip-ta_remote +# application/vnd.ntt-local.sip-ta_tcp_stream +application/vnd.oasis.opendocument.chart odc +application/vnd.oasis.opendocument.chart-template otc +application/vnd.oasis.opendocument.database odb +application/vnd.oasis.opendocument.formula odf +application/vnd.oasis.opendocument.formula-template odft +application/vnd.oasis.opendocument.graphics odg +application/vnd.oasis.opendocument.graphics-template otg +application/vnd.oasis.opendocument.image odi +application/vnd.oasis.opendocument.image-template oti +application/vnd.oasis.opendocument.presentation odp +application/vnd.oasis.opendocument.presentation-template otp +application/vnd.oasis.opendocument.spreadsheet ods +application/vnd.oasis.opendocument.spreadsheet-template ots +application/vnd.oasis.opendocument.text odt +application/vnd.oasis.opendocument.text-master odm +application/vnd.oasis.opendocument.text-template ott +application/vnd.oasis.opendocument.text-web oth +# application/vnd.obn +# application/vnd.oftn.l10n+json +# application/vnd.oipf.contentaccessdownload+xml +# application/vnd.oipf.contentaccessstreaming+xml +# application/vnd.oipf.cspg-hexbinary +# application/vnd.oipf.dae.svg+xml +# application/vnd.oipf.dae.xhtml+xml +# application/vnd.oipf.mippvcontrolmessage+xml +# application/vnd.oipf.pae.gem +# application/vnd.oipf.spdiscovery+xml +# application/vnd.oipf.spdlist+xml +# application/vnd.oipf.ueprofile+xml +# application/vnd.oipf.userprofile+xml +application/vnd.olpc-sugar xo +# application/vnd.oma-scws-config +# application/vnd.oma-scws-http-request +# application/vnd.oma-scws-http-response +# application/vnd.oma.bcast.associated-procedure-parameter+xml +# application/vnd.oma.bcast.drm-trigger+xml +# application/vnd.oma.bcast.imd+xml +# application/vnd.oma.bcast.ltkm +# application/vnd.oma.bcast.notification+xml +# application/vnd.oma.bcast.provisioningtrigger +# application/vnd.oma.bcast.sgboot +# application/vnd.oma.bcast.sgdd+xml +# application/vnd.oma.bcast.sgdu +# application/vnd.oma.bcast.simple-symbol-container +# application/vnd.oma.bcast.smartcard-trigger+xml +# application/vnd.oma.bcast.sprov+xml +# application/vnd.oma.bcast.stkm +# application/vnd.oma.cab-address-book+xml +# application/vnd.oma.cab-feature-handler+xml +# application/vnd.oma.cab-pcc+xml +# application/vnd.oma.cab-subs-invite+xml +# application/vnd.oma.cab-user-prefs+xml +# application/vnd.oma.dcd +# application/vnd.oma.dcdc +application/vnd.oma.dd2+xml dd2 +# application/vnd.oma.drm.risd+xml +# application/vnd.oma.group-usage-list+xml +# application/vnd.oma.lwm2m+json +# application/vnd.oma.lwm2m+tlv +# application/vnd.oma.pal+xml +# application/vnd.oma.poc.detailed-progress-report+xml +# application/vnd.oma.poc.final-report+xml +# application/vnd.oma.poc.groups+xml +# application/vnd.oma.poc.invocation-descriptor+xml +# application/vnd.oma.poc.optimized-progress-report+xml +# application/vnd.oma.push +# application/vnd.oma.scidm.messages+xml +# application/vnd.oma.xcap-directory+xml +# application/vnd.omads-email+xml +# application/vnd.omads-file+xml +# application/vnd.omads-folder+xml +# application/vnd.omaloc-supl-init +# application/vnd.onepager +# application/vnd.openblox.game+xml +# application/vnd.openblox.game-binary +# application/vnd.openeye.oeb +application/vnd.openofficeorg.extension oxt +# application/vnd.openxmlformats-officedocument.custom-properties+xml +# application/vnd.openxmlformats-officedocument.customxmlproperties+xml +# application/vnd.openxmlformats-officedocument.drawing+xml +# application/vnd.openxmlformats-officedocument.drawingml.chart+xml +# application/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml +# application/vnd.openxmlformats-officedocument.drawingml.diagramcolors+xml +# application/vnd.openxmlformats-officedocument.drawingml.diagramdata+xml +# application/vnd.openxmlformats-officedocument.drawingml.diagramlayout+xml +# application/vnd.openxmlformats-officedocument.drawingml.diagramstyle+xml +# application/vnd.openxmlformats-officedocument.extended-properties+xml +# application/vnd.openxmlformats-officedocument.presentationml.commentauthors+xml +# application/vnd.openxmlformats-officedocument.presentationml.comments+xml +# application/vnd.openxmlformats-officedocument.presentationml.handoutmaster+xml +# application/vnd.openxmlformats-officedocument.presentationml.notesmaster+xml +# application/vnd.openxmlformats-officedocument.presentationml.notesslide+xml +application/vnd.openxmlformats-officedocument.presentationml.presentation pptx +# application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml +# application/vnd.openxmlformats-officedocument.presentationml.presprops+xml +application/vnd.openxmlformats-officedocument.presentationml.slide sldx +# application/vnd.openxmlformats-officedocument.presentationml.slide+xml +# application/vnd.openxmlformats-officedocument.presentationml.slidelayout+xml +# application/vnd.openxmlformats-officedocument.presentationml.slidemaster+xml +application/vnd.openxmlformats-officedocument.presentationml.slideshow ppsx +# application/vnd.openxmlformats-officedocument.presentationml.slideshow.main+xml +# application/vnd.openxmlformats-officedocument.presentationml.slideupdateinfo+xml +# application/vnd.openxmlformats-officedocument.presentationml.tablestyles+xml +# application/vnd.openxmlformats-officedocument.presentationml.tags+xml +application/vnd.openxmlformats-officedocument.presentationml.template potx +# application/vnd.openxmlformats-officedocument.presentationml.template.main+xml +# application/vnd.openxmlformats-officedocument.presentationml.viewprops+xml +# application/vnd.openxmlformats-officedocument.spreadsheetml.calcchain+xml +# application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml +# application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml +# application/vnd.openxmlformats-officedocument.spreadsheetml.connections+xml +# application/vnd.openxmlformats-officedocument.spreadsheetml.dialogsheet+xml +# application/vnd.openxmlformats-officedocument.spreadsheetml.externallink+xml +# application/vnd.openxmlformats-officedocument.spreadsheetml.pivotcachedefinition+xml +# application/vnd.openxmlformats-officedocument.spreadsheetml.pivotcacherecords+xml +# application/vnd.openxmlformats-officedocument.spreadsheetml.pivottable+xml +# application/vnd.openxmlformats-officedocument.spreadsheetml.querytable+xml +# application/vnd.openxmlformats-officedocument.spreadsheetml.revisionheaders+xml +# application/vnd.openxmlformats-officedocument.spreadsheetml.revisionlog+xml +# application/vnd.openxmlformats-officedocument.spreadsheetml.sharedstrings+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.sheet xlsx +# application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml +# application/vnd.openxmlformats-officedocument.spreadsheetml.sheetmetadata+xml +# application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml +# application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml +# application/vnd.openxmlformats-officedocument.spreadsheetml.tablesinglecells+xml +application/vnd.openxmlformats-officedocument.spreadsheetml.template xltx +# application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml +# application/vnd.openxmlformats-officedocument.spreadsheetml.usernames+xml +# application/vnd.openxmlformats-officedocument.spreadsheetml.volatiledependencies+xml +# application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml +# application/vnd.openxmlformats-officedocument.theme+xml +# application/vnd.openxmlformats-officedocument.themeoverride+xml +# application/vnd.openxmlformats-officedocument.vmldrawing +# application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml +application/vnd.openxmlformats-officedocument.wordprocessingml.document docx +# application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml +# application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml +# application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml +# application/vnd.openxmlformats-officedocument.wordprocessingml.fonttable+xml +# application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml +# application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml +# application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml +# application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml +# application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml +application/vnd.openxmlformats-officedocument.wordprocessingml.template dotx +# application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml +# application/vnd.openxmlformats-officedocument.wordprocessingml.websettings+xml +# application/vnd.openxmlformats-package.core-properties+xml +# application/vnd.openxmlformats-package.digital-signature-xmlsignature+xml +# application/vnd.openxmlformats-package.relationships+xml +# application/vnd.oracle.resource+json +# application/vnd.orange.indata +# application/vnd.osa.netdeploy +application/vnd.osgeo.mapguide.package mgp +# application/vnd.osgi.bundle +application/vnd.osgi.dp dp +application/vnd.osgi.subsystem esa +# application/vnd.otps.ct-kip+xml +# application/vnd.oxli.countgraph +# application/vnd.pagerduty+json +application/vnd.palm pdb pqa oprc +# application/vnd.panoply +# application/vnd.paos.xml +application/vnd.pawaafile paw +# application/vnd.pcos +application/vnd.pg.format str +application/vnd.pg.osasli ei6 +# application/vnd.piaccess.application-licence +application/vnd.picsel efif +application/vnd.pmi.widget wg +# application/vnd.poc.group-advertisement+xml +application/vnd.pocketlearn plf +application/vnd.powerbuilder6 pbd +# application/vnd.powerbuilder6-s +# application/vnd.powerbuilder7 +# application/vnd.powerbuilder7-s +# application/vnd.powerbuilder75 +# application/vnd.powerbuilder75-s +# application/vnd.preminet +application/vnd.previewsystems.box box +application/vnd.proteus.magazine mgz +application/vnd.publishare-delta-tree qps +application/vnd.pvi.ptid1 ptid +# application/vnd.pwg-multiplexed +# application/vnd.pwg-xhtml-print+xml +# application/vnd.qualcomm.brew-app-res +# application/vnd.quarantainenet +application/vnd.quark.quarkxpress qxd qxt qwd qwt qxl qxb +# application/vnd.quobject-quoxdocument +# application/vnd.radisys.moml+xml +# application/vnd.radisys.msml+xml +# application/vnd.radisys.msml-audit+xml +# application/vnd.radisys.msml-audit-conf+xml +# application/vnd.radisys.msml-audit-conn+xml +# application/vnd.radisys.msml-audit-dialog+xml +# application/vnd.radisys.msml-audit-stream+xml +# application/vnd.radisys.msml-conf+xml +# application/vnd.radisys.msml-dialog+xml +# application/vnd.radisys.msml-dialog-base+xml +# application/vnd.radisys.msml-dialog-fax-detect+xml +# application/vnd.radisys.msml-dialog-fax-sendrecv+xml +# application/vnd.radisys.msml-dialog-group+xml +# application/vnd.radisys.msml-dialog-speech+xml +# application/vnd.radisys.msml-dialog-transform+xml +# application/vnd.rainstor.data +# application/vnd.rapid +# application/vnd.rar +application/vnd.realvnc.bed bed +application/vnd.recordare.musicxml mxl +application/vnd.recordare.musicxml+xml musicxml +# application/vnd.renlearn.rlprint +application/vnd.rig.cryptonote cryptonote +application/vnd.rim.cod cod +application/vnd.rn-realmedia rm +application/vnd.rn-realmedia-vbr rmvb +application/vnd.route66.link66+xml link66 +# application/vnd.rs-274x +# application/vnd.ruckus.download +# application/vnd.s3sms +application/vnd.sailingtracker.track st +# application/vnd.sbm.cid +# application/vnd.sbm.mid2 +# application/vnd.scribus +# application/vnd.sealed.3df +# application/vnd.sealed.csf +# application/vnd.sealed.doc +# application/vnd.sealed.eml +# application/vnd.sealed.mht +# application/vnd.sealed.net +# application/vnd.sealed.ppt +# application/vnd.sealed.tiff +# application/vnd.sealed.xls +# application/vnd.sealedmedia.softseal.html +# application/vnd.sealedmedia.softseal.pdf +application/vnd.seemail see +application/vnd.sema sema +application/vnd.semd semd +application/vnd.semf semf +application/vnd.shana.informed.formdata ifm +application/vnd.shana.informed.formtemplate itp +application/vnd.shana.informed.interchange iif +application/vnd.shana.informed.package ipk +application/vnd.simtech-mindmapper twd twds +# application/vnd.siren+json +application/vnd.smaf mmf +# application/vnd.smart.notebook +application/vnd.smart.teacher teacher +# application/vnd.software602.filler.form+xml +# application/vnd.software602.filler.form-xml-zip +application/vnd.solent.sdkm+xml sdkm sdkd +application/vnd.spotfire.dxp dxp +application/vnd.spotfire.sfs sfs +# application/vnd.sss-cod +# application/vnd.sss-dtf +# application/vnd.sss-ntf +application/vnd.stardivision.calc sdc +application/vnd.stardivision.draw sda +application/vnd.stardivision.impress sdd +application/vnd.stardivision.math smf +application/vnd.stardivision.writer sdw vor +application/vnd.stardivision.writer-global sgl +application/vnd.stepmania.package smzip +application/vnd.stepmania.stepchart sm +# application/vnd.street-stream +# application/vnd.sun.wadl+xml +application/vnd.sun.xml.calc sxc +application/vnd.sun.xml.calc.template stc +application/vnd.sun.xml.draw sxd +application/vnd.sun.xml.draw.template std +application/vnd.sun.xml.impress sxi +application/vnd.sun.xml.impress.template sti +application/vnd.sun.xml.math sxm +application/vnd.sun.xml.writer sxw +application/vnd.sun.xml.writer.global sxg +application/vnd.sun.xml.writer.template stw +application/vnd.sus-calendar sus susp +application/vnd.svd svd +# application/vnd.swiftview-ics +application/vnd.symbian.install sis sisx +application/vnd.syncml+xml xsm +application/vnd.syncml.dm+wbxml bdm +application/vnd.syncml.dm+xml xdm +# application/vnd.syncml.dm.notification +# application/vnd.syncml.dmddf+wbxml +# application/vnd.syncml.dmddf+xml +# application/vnd.syncml.dmtnds+wbxml +# application/vnd.syncml.dmtnds+xml +# application/vnd.syncml.ds.notification +application/vnd.tao.intent-module-archive tao +application/vnd.tcpdump.pcap pcap cap dmp +# application/vnd.tmd.mediaflex.api+xml +# application/vnd.tml +application/vnd.tmobile-livetv tmo +application/vnd.trid.tpt tpt +application/vnd.triscape.mxs mxs +application/vnd.trueapp tra +# application/vnd.truedoc +# application/vnd.ubisoft.webplayer +application/vnd.ufdl ufd ufdl +application/vnd.uiq.theme utz +application/vnd.umajin umj +application/vnd.unity unityweb +application/vnd.uoml+xml uoml +# application/vnd.uplanet.alert +# application/vnd.uplanet.alert-wbxml +# application/vnd.uplanet.bearer-choice +# application/vnd.uplanet.bearer-choice-wbxml +# application/vnd.uplanet.cacheop +# application/vnd.uplanet.cacheop-wbxml +# application/vnd.uplanet.channel +# application/vnd.uplanet.channel-wbxml +# application/vnd.uplanet.list +# application/vnd.uplanet.list-wbxml +# application/vnd.uplanet.listcmd +# application/vnd.uplanet.listcmd-wbxml +# application/vnd.uplanet.signal +# application/vnd.uri-map +# application/vnd.valve.source.material +application/vnd.vcx vcx +# application/vnd.vd-study +# application/vnd.vectorworks +# application/vnd.vel+json +# application/vnd.verimatrix.vcas +# application/vnd.vidsoft.vidconference +application/vnd.visio vsd vst vss vsw +application/vnd.visionary vis +# application/vnd.vividence.scriptfile +application/vnd.vsf vsf +# application/vnd.wap.sic +# application/vnd.wap.slc +application/vnd.wap.wbxml wbxml +application/vnd.wap.wmlc wmlc +application/vnd.wap.wmlscriptc wmlsc +application/vnd.webturbo wtb +# application/vnd.wfa.p2p +# application/vnd.wfa.wsc +# application/vnd.windows.devicepairing +# application/vnd.wmc +# application/vnd.wmf.bootstrap +# application/vnd.wolfram.mathematica +# application/vnd.wolfram.mathematica.package +application/vnd.wolfram.player nbp +application/vnd.wordperfect wpd +application/vnd.wqd wqd +# application/vnd.wrq-hp3000-labelled +application/vnd.wt.stf stf +# application/vnd.wv.csp+wbxml +# application/vnd.wv.csp+xml +# application/vnd.wv.ssp+xml +# application/vnd.xacml+json +application/vnd.xara xar +application/vnd.xfdl xfdl +# application/vnd.xfdl.webform +# application/vnd.xmi+xml +# application/vnd.xmpie.cpkg +# application/vnd.xmpie.dpkg +# application/vnd.xmpie.plan +# application/vnd.xmpie.ppkg +# application/vnd.xmpie.xlim +application/vnd.yamaha.hv-dic hvd +application/vnd.yamaha.hv-script hvs +application/vnd.yamaha.hv-voice hvp +application/vnd.yamaha.openscoreformat osf +application/vnd.yamaha.openscoreformat.osfpvg+xml osfpvg +# application/vnd.yamaha.remote-setup +application/vnd.yamaha.smaf-audio saf +application/vnd.yamaha.smaf-phrase spf +# application/vnd.yamaha.through-ngn +# application/vnd.yamaha.tunnel-udpencap +# application/vnd.yaoweme +application/vnd.yellowriver-custom-menu cmp +application/vnd.zul zir zirz +application/vnd.zzazz.deck+xml zaz +application/voicexml+xml vxml +# application/vq-rtcpxr +# application/watcherinfo+xml +# application/whoispp-query +# application/whoispp-response +application/widget wgt +application/winhlp hlp +# application/wita +# application/wordperfect5.1 +application/wsdl+xml wsdl +application/wspolicy+xml wspolicy +application/x-7z-compressed 7z +application/x-abiword abw +application/x-ace-compressed ace +# application/x-amf +application/x-apple-diskimage dmg +application/x-authorware-bin aab x32 u32 vox +application/x-authorware-map aam +application/x-authorware-seg aas +application/x-bcpio bcpio +application/x-bittorrent torrent +application/x-blorb blb blorb +application/x-bzip bz +application/x-bzip2 bz2 boz +application/x-cbr cbr cba cbt cbz cb7 +application/x-cdlink vcd +application/x-cfs-compressed cfs +application/x-chat chat +application/x-chess-pgn pgn +# application/x-compress +application/x-conference nsc +application/x-cpio cpio +application/x-csh csh +application/x-debian-package deb udeb +application/x-dgc-compressed dgc +application/x-director dir dcr dxr cst cct cxt w3d fgd swa +application/x-doom wad +application/x-dtbncx+xml ncx +application/x-dtbook+xml dtb +application/x-dtbresource+xml res +application/x-dvi dvi +application/x-envoy evy +application/x-eva eva +application/x-font-bdf bdf +# application/x-font-dos +# application/x-font-framemaker +application/x-font-ghostscript gsf +# application/x-font-libgrx +application/x-font-linux-psf psf +application/x-font-pcf pcf +application/x-font-snf snf +# application/x-font-speedo +# application/x-font-sunos-news +application/x-font-type1 pfa pfb pfm afm +# application/x-font-vfont +application/x-freearc arc +application/x-futuresplash spl +application/x-gca-compressed gca +application/x-glulx ulx +application/x-gnumeric gnumeric +application/x-gramps-xml gramps +application/x-gtar gtar +# application/x-gzip +application/x-hdf hdf +application/x-install-instructions install +application/x-iso9660-image iso +application/x-java-jnlp-file jnlp +application/x-latex latex +application/x-lzh-compressed lzh lha +application/x-mie mie +application/x-mobipocket-ebook prc mobi +application/x-ms-application application +application/x-ms-shortcut lnk +application/x-ms-wmd wmd +application/x-ms-wmz wmz +application/x-ms-xbap xbap +application/x-msaccess mdb +application/x-msbinder obd +application/x-mscardfile crd +application/x-msclip clp +application/x-msdownload exe dll com bat msi +application/x-msmediaview mvb m13 m14 +application/x-msmetafile wmf wmz emf emz +application/x-msmoney mny +application/x-mspublisher pub +application/x-msschedule scd +application/x-msterminal trm +application/x-mswrite wri +application/x-netcdf nc cdf +application/x-nzb nzb +application/x-pkcs12 p12 pfx +application/x-pkcs7-certificates p7b spc +application/x-pkcs7-certreqresp p7r +application/x-rar-compressed rar +application/x-research-info-systems ris +application/x-sh sh +application/x-shar shar +application/x-shockwave-flash swf +application/x-silverlight-app xap +application/x-sql sql +application/x-stuffit sit +application/x-stuffitx sitx +application/x-subrip srt +application/x-sv4cpio sv4cpio +application/x-sv4crc sv4crc +application/x-t3vm-image t3 +application/x-tads gam +application/x-tar tar +application/x-tcl tcl +application/x-tex tex +application/x-tex-tfm tfm +application/x-texinfo texinfo texi +application/x-tgif obj +application/x-ustar ustar +application/x-wais-source src +# application/x-www-form-urlencoded +application/x-x509-ca-cert der crt +application/x-xfig fig +application/x-xliff+xml xlf +application/x-xpinstall xpi +application/x-xz xz +application/x-zmachine z1 z2 z3 z4 z5 z6 z7 z8 +# application/x400-bp +# application/xacml+xml +application/xaml+xml xaml +# application/xcap-att+xml +# application/xcap-caps+xml +application/xcap-diff+xml xdf +# application/xcap-el+xml +# application/xcap-error+xml +# application/xcap-ns+xml +# application/xcon-conference-info+xml +# application/xcon-conference-info-diff+xml +application/xenc+xml xenc +application/xhtml+xml xhtml xht +# application/xhtml-voice+xml +application/xml xml xsl +application/xml-dtd dtd +# application/xml-external-parsed-entity +# application/xml-patch+xml +# application/xmpp+xml +application/xop+xml xop +application/xproc+xml xpl +application/xslt+xml xslt +application/xspf+xml xspf +application/xv+xml mxml xhvml xvml xvm +application/yang yang +application/yin+xml yin +application/zip zip +# application/zlib +# audio/1d-interleaved-parityfec +# audio/32kadpcm +# audio/3gpp +# audio/3gpp2 +# audio/ac3 +audio/adpcm adp +# audio/amr +# audio/amr-wb +# audio/amr-wb+ +# audio/aptx +# audio/asc +# audio/atrac-advanced-lossless +# audio/atrac-x +# audio/atrac3 +audio/basic au snd +# audio/bv16 +# audio/bv32 +# audio/clearmode +# audio/cn +# audio/dat12 +# audio/dls +# audio/dsr-es201108 +# audio/dsr-es202050 +# audio/dsr-es202211 +# audio/dsr-es202212 +# audio/dv +# audio/dvi4 +# audio/eac3 +# audio/encaprtp +# audio/evrc +# audio/evrc-qcp +# audio/evrc0 +# audio/evrc1 +# audio/evrcb +# audio/evrcb0 +# audio/evrcb1 +# audio/evrcnw +# audio/evrcnw0 +# audio/evrcnw1 +# audio/evrcwb +# audio/evrcwb0 +# audio/evrcwb1 +# audio/evs +# audio/example +# audio/fwdred +# audio/g711-0 +# audio/g719 +# audio/g722 +# audio/g7221 +# audio/g723 +# audio/g726-16 +# audio/g726-24 +# audio/g726-32 +# audio/g726-40 +# audio/g728 +# audio/g729 +# audio/g7291 +# audio/g729d +# audio/g729e +# audio/gsm +# audio/gsm-efr +# audio/gsm-hr-08 +# audio/ilbc +# audio/ip-mr_v2.5 +# audio/isac +# audio/l16 +# audio/l20 +# audio/l24 +# audio/l8 +# audio/lpc +audio/midi mid midi kar rmi +# audio/mobile-xmf +audio/mp4 m4a mp4a +# audio/mp4a-latm +# audio/mpa +# audio/mpa-robust +audio/mpeg mpga mp2 mp2a mp3 m2a m3a +# audio/mpeg4-generic +# audio/musepack +audio/ogg oga ogg spx +# audio/opus +# audio/parityfec +# audio/pcma +# audio/pcma-wb +# audio/pcmu +# audio/pcmu-wb +# audio/prs.sid +# audio/qcelp +# audio/raptorfec +# audio/red +# audio/rtp-enc-aescm128 +# audio/rtp-midi +# audio/rtploopback +# audio/rtx +audio/s3m s3m +audio/silk sil +# audio/smv +# audio/smv-qcp +# audio/smv0 +# audio/sp-midi +# audio/speex +# audio/t140c +# audio/t38 +# audio/telephone-event +# audio/tone +# audio/uemclip +# audio/ulpfec +# audio/vdvi +# audio/vmr-wb +# audio/vnd.3gpp.iufp +# audio/vnd.4sb +# audio/vnd.audiokoz +# audio/vnd.celp +# audio/vnd.cisco.nse +# audio/vnd.cmles.radio-events +# audio/vnd.cns.anp1 +# audio/vnd.cns.inf1 +audio/vnd.dece.audio uva uvva +audio/vnd.digital-winds eol +# audio/vnd.dlna.adts +# audio/vnd.dolby.heaac.1 +# audio/vnd.dolby.heaac.2 +# audio/vnd.dolby.mlp +# audio/vnd.dolby.mps +# audio/vnd.dolby.pl2 +# audio/vnd.dolby.pl2x +# audio/vnd.dolby.pl2z +# audio/vnd.dolby.pulse.1 +audio/vnd.dra dra +audio/vnd.dts dts +audio/vnd.dts.hd dtshd +# audio/vnd.dvb.file +# audio/vnd.everad.plj +# audio/vnd.hns.audio +audio/vnd.lucent.voice lvp +audio/vnd.ms-playready.media.pya pya +# audio/vnd.nokia.mobile-xmf +# audio/vnd.nortel.vbk +audio/vnd.nuera.ecelp4800 ecelp4800 +audio/vnd.nuera.ecelp7470 ecelp7470 +audio/vnd.nuera.ecelp9600 ecelp9600 +# audio/vnd.octel.sbc +# audio/vnd.qcelp +# audio/vnd.rhetorex.32kadpcm +audio/vnd.rip rip +# audio/vnd.sealedmedia.softseal.mpeg +# audio/vnd.vmx.cvsd +# audio/vorbis +# audio/vorbis-config +audio/webm weba +audio/x-aac aac +audio/x-aiff aif aiff aifc +audio/x-caf caf +audio/x-flac flac +audio/x-matroska mka +audio/x-mpegurl m3u +audio/x-ms-wax wax +audio/x-ms-wma wma +audio/x-pn-realaudio ram ra +audio/x-pn-realaudio-plugin rmp +# audio/x-tta +audio/x-wav wav +audio/xm xm +chemical/x-cdx cdx +chemical/x-cif cif +chemical/x-cmdf cmdf +chemical/x-cml cml +chemical/x-csml csml +# chemical/x-pdb +chemical/x-xyz xyz +font/collection ttc +font/otf otf +# font/sfnt +font/ttf ttf +font/woff woff +font/woff2 woff2 +image/bmp bmp +image/cgm cgm +# image/dicom-rle +# image/emf +# image/example +# image/fits +image/g3fax g3 +image/gif gif +image/ief ief +# image/jls +# image/jp2 +image/jpeg jpeg jpg jpe +# image/jpm +# image/jpx +image/ktx ktx +# image/naplps +image/png png +image/prs.btif btif +# image/prs.pti +# image/pwg-raster +image/sgi sgi +image/svg+xml svg svgz +# image/t38 +image/tiff tiff tif +# image/tiff-fx +image/vnd.adobe.photoshop psd +# image/vnd.airzip.accelerator.azv +# image/vnd.cns.inf2 +image/vnd.dece.graphic uvi uvvi uvg uvvg +image/vnd.djvu djvu djv +image/vnd.dvb.subtitle sub +image/vnd.dwg dwg +image/vnd.dxf dxf +image/vnd.fastbidsheet fbs +image/vnd.fpx fpx +image/vnd.fst fst +image/vnd.fujixerox.edmics-mmr mmr +image/vnd.fujixerox.edmics-rlc rlc +# image/vnd.globalgraphics.pgb +# image/vnd.microsoft.icon +# image/vnd.mix +# image/vnd.mozilla.apng +image/vnd.ms-modi mdi +image/vnd.ms-photo wdp +image/vnd.net-fpx npx +# image/vnd.radiance +# image/vnd.sealed.png +# image/vnd.sealedmedia.softseal.gif +# image/vnd.sealedmedia.softseal.jpg +# image/vnd.svf +# image/vnd.tencent.tap +# image/vnd.valve.source.texture +image/vnd.wap.wbmp wbmp +image/vnd.xiff xif +# image/vnd.zbrush.pcx +image/webp webp +# image/wmf +image/x-3ds 3ds +image/x-cmu-raster ras +image/x-cmx cmx +image/x-freehand fh fhc fh4 fh5 fh7 +image/x-icon ico +image/x-mrsid-image sid +image/x-pcx pcx +image/x-pict pic pct +image/x-portable-anymap pnm +image/x-portable-bitmap pbm +image/x-portable-graymap pgm +image/x-portable-pixmap ppm +image/x-rgb rgb +image/x-tga tga +image/x-xbitmap xbm +image/x-xpixmap xpm +image/x-xwindowdump xwd +# message/cpim +# message/delivery-status +# message/disposition-notification +# message/example +# message/external-body +# message/feedback-report +# message/global +# message/global-delivery-status +# message/global-disposition-notification +# message/global-headers +# message/http +# message/imdn+xml +# message/news +# message/partial +message/rfc822 eml mime +# message/s-http +# message/sip +# message/sipfrag +# message/tracking-status +# message/vnd.si.simp +# message/vnd.wfa.wsc +# model/example +# model/gltf+json +model/iges igs iges +model/mesh msh mesh silo +model/vnd.collada+xml dae +model/vnd.dwf dwf +# model/vnd.flatland.3dml +model/vnd.gdl gdl +# model/vnd.gs-gdl +# model/vnd.gs.gdl +model/vnd.gtw gtw +# model/vnd.moml+xml +model/vnd.mts mts +# model/vnd.opengex +# model/vnd.parasolid.transmit.binary +# model/vnd.parasolid.transmit.text +# model/vnd.rosette.annotated-data-model +# model/vnd.valve.source.compiled-map +model/vnd.vtu vtu +model/vrml wrl vrml +model/x3d+binary x3db x3dbz +# model/x3d+fastinfoset +model/x3d+vrml x3dv x3dvz +model/x3d+xml x3d x3dz +# model/x3d-vrml +# multipart/alternative +# multipart/appledouble +# multipart/byteranges +# multipart/digest +# multipart/encrypted +# multipart/example +# multipart/form-data +# multipart/header-set +# multipart/mixed +# multipart/parallel +# multipart/related +# multipart/report +# multipart/signed +# multipart/voice-message +# multipart/x-mixed-replace +# text/1d-interleaved-parityfec +text/cache-manifest appcache +text/calendar ics ifb +text/css css +text/csv csv +# text/csv-schema +# text/directory +# text/dns +# text/ecmascript +# text/encaprtp +# text/enriched +# text/example +# text/fwdred +# text/grammar-ref-list +text/html html htm +# text/javascript +# text/jcr-cnd +# text/markdown +# text/mizar +text/n3 n3 +# text/parameters +# text/parityfec +text/plain txt text conf def list log in +# text/provenance-notation +# text/prs.fallenstein.rst +text/prs.lines.tag dsc +# text/prs.prop.logic +# text/raptorfec +# text/red +# text/rfc822-headers +text/richtext rtx +# text/rtf +# text/rtp-enc-aescm128 +# text/rtploopback +# text/rtx +text/sgml sgml sgm +# text/t140 +text/tab-separated-values tsv +text/troff t tr roff man me ms +text/turtle ttl +# text/ulpfec +text/uri-list uri uris urls +text/vcard vcard +# text/vnd.a +# text/vnd.abc +text/vnd.curl curl +text/vnd.curl.dcurl dcurl +text/vnd.curl.mcurl mcurl +text/vnd.curl.scurl scurl +# text/vnd.debian.copyright +# text/vnd.dmclientscript +text/vnd.dvb.subtitle sub +# text/vnd.esmertec.theme-descriptor +text/vnd.fly fly +text/vnd.fmi.flexstor flx +text/vnd.graphviz gv +text/vnd.in3d.3dml 3dml +text/vnd.in3d.spot spot +# text/vnd.iptc.newsml +# text/vnd.iptc.nitf +# text/vnd.latex-z +# text/vnd.motorola.reflex +# text/vnd.ms-mediapackage +# text/vnd.net2phone.commcenter.command +# text/vnd.radisys.msml-basic-layout +# text/vnd.si.uricatalogue +text/vnd.sun.j2me.app-descriptor jad +# text/vnd.trolltech.linguist +# text/vnd.wap.si +# text/vnd.wap.sl +text/vnd.wap.wml wml +text/vnd.wap.wmlscript wmls +text/x-asm s asm +text/x-c c cc cxx cpp h hh dic +text/x-fortran f for f77 f90 +text/x-java-source java +text/x-nfo nfo +text/x-opml opml +text/x-pascal p pas +text/x-setext etx +text/x-sfv sfv +text/x-uuencode uu +text/x-vcalendar vcs +text/x-vcard vcf +# text/xml +# text/xml-external-parsed-entity +# video/1d-interleaved-parityfec +video/3gpp 3gp +# video/3gpp-tt +video/3gpp2 3g2 +# video/bmpeg +# video/bt656 +# video/celb +# video/dv +# video/encaprtp +# video/example +video/h261 h261 +video/h263 h263 +# video/h263-1998 +# video/h263-2000 +video/h264 h264 +# video/h264-rcdo +# video/h264-svc +# video/h265 +# video/iso.segment +video/jpeg jpgv +# video/jpeg2000 +video/jpm jpm jpgm +video/mj2 mj2 mjp2 +# video/mp1s +# video/mp2p +# video/mp2t +video/mp4 mp4 mp4v mpg4 +# video/mp4v-es +video/mpeg mpeg mpg mpe m1v m2v +# video/mpeg4-generic +# video/mpv +# video/nv +video/ogg ogv +# video/parityfec +# video/pointer +video/quicktime qt mov +# video/raptorfec +# video/raw +# video/rtp-enc-aescm128 +# video/rtploopback +# video/rtx +# video/smpte292m +# video/ulpfec +# video/vc1 +# video/vnd.cctv +video/vnd.dece.hd uvh uvvh +video/vnd.dece.mobile uvm uvvm +# video/vnd.dece.mp4 +video/vnd.dece.pd uvp uvvp +video/vnd.dece.sd uvs uvvs +video/vnd.dece.video uvv uvvv +# video/vnd.directv.mpeg +# video/vnd.directv.mpeg-tts +# video/vnd.dlna.mpeg-tts +video/vnd.dvb.file dvb +video/vnd.fvt fvt +# video/vnd.hns.video +# video/vnd.iptvforum.1dparityfec-1010 +# video/vnd.iptvforum.1dparityfec-2005 +# video/vnd.iptvforum.2dparityfec-1010 +# video/vnd.iptvforum.2dparityfec-2005 +# video/vnd.iptvforum.ttsavc +# video/vnd.iptvforum.ttsmpeg2 +# video/vnd.motorola.video +# video/vnd.motorola.videop +video/vnd.mpegurl mxu m4u +video/vnd.ms-playready.media.pyv pyv +# video/vnd.nokia.interleaved-multimedia +# video/vnd.nokia.videovoip +# video/vnd.objectvideo +# video/vnd.radgamettools.bink +# video/vnd.radgamettools.smacker +# video/vnd.sealed.mpeg1 +# video/vnd.sealed.mpeg4 +# video/vnd.sealed.swf +# video/vnd.sealedmedia.softseal.mov +video/vnd.uvvu.mp4 uvu uvvu +video/vnd.vivo viv +# video/vp8 +video/webm webm +video/x-f4v f4v +video/x-fli fli +video/x-flv flv +video/x-m4v m4v +video/x-matroska mkv mk3d mks +video/x-mng mng +video/x-ms-asf asf asx +video/x-ms-vob vob +video/x-ms-wm wm +video/x-ms-wmv wmv +video/x-ms-wmx wmx +video/x-ms-wvx wvx +video/x-msvideo avi +video/x-sgi-movie movie +video/x-smv smv +x-conference/x-cooltalk ice diff --git a/dspace/modules/pom.xml b/dspace/modules/pom.xml index 04ecf6c5efa..036dd7c2f25 100644 --- a/dspace/modules/pom.xml +++ b/dspace/modules/pom.xml @@ -11,7 +11,7 @@ org.dspace dspace-parent - 7.6.5 + 7.6.7 ../../pom.xml diff --git a/dspace/modules/rest/pom.xml b/dspace/modules/rest/pom.xml index c182a7ab3b0..470626505c4 100644 --- a/dspace/modules/rest/pom.xml +++ b/dspace/modules/rest/pom.xml @@ -13,7 +13,7 @@ org.dspace modules - 7.6.5 + 7.6.7 .. diff --git a/dspace/modules/server/pom.xml b/dspace/modules/server/pom.xml index 56a2ecdfdaf..ea334423ac7 100644 --- a/dspace/modules/server/pom.xml +++ b/dspace/modules/server/pom.xml @@ -13,7 +13,7 @@ just adding new jar in the classloader modules org.dspace - 7.6.5 + 7.6.7 .. diff --git a/dspace/pom.xml b/dspace/pom.xml index 8e1060a432c..ddea9bac0ba 100644 --- a/dspace/pom.xml +++ b/dspace/pom.xml @@ -16,7 +16,7 @@ org.dspace dspace-parent - 7.6.5 + 7.6.7 ../pom.xml diff --git a/dspace/solr/search/conf/schema.xml b/dspace/solr/search/conf/schema.xml index 275d9878490..c8139ffdb75 100644 --- a/dspace/solr/search/conf/schema.xml +++ b/dspace/solr/search/conf/schema.xml @@ -281,7 +281,7 @@ - + @@ -329,7 +329,7 @@ - + diff --git a/dspace/solr/search/conf/solrconfig.xml b/dspace/solr/search/conf/solrconfig.xml index 97b1d1ddbbf..71c6c884694 100644 --- a/dspace/solr/search/conf/solrconfig.xml +++ b/dspace/solr/search/conf/solrconfig.xml @@ -148,6 +148,12 @@ + + + + + + false diff --git a/dspace/src/main/docker-compose/cli.assetstore.yml b/dspace/src/main/docker-compose/cli.assetstore.yml index 6563aa081eb..ab9527f8e4a 100644 --- a/dspace/src/main/docker-compose/cli.assetstore.yml +++ b/dspace/src/main/docker-compose/cli.assetstore.yml @@ -10,7 +10,7 @@ services: dspace-cli: environment: # This assetstore zip is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data - - LOADASSETS=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/assetstore.tar.gz + - LOADASSETS=${LOADASSETS:-https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/assetstore.tar.gz} entrypoint: - /bin/bash - '-c' diff --git a/dspace/src/main/docker-compose/cli.ingest.yml b/dspace/src/main/docker-compose/cli.ingest.yml index 3a5957a79cc..de5a47e66dd 100644 --- a/dspace/src/main/docker-compose/cli.ingest.yml +++ b/dspace/src/main/docker-compose/cli.ingest.yml @@ -9,9 +9,9 @@ services: dspace-cli: environment: - - AIPZIP=https://github.com/DSpace-Labs/AIP-Files/raw/main/dogAndReport.zip - - ADMIN_EMAIL=dspace.admin.dev@dataquest.sk - - AIPDIR=/tmp/aip-dir + - AIPZIP=${AIPZIP:-https://github.com/DSpace-Labs/AIP-Files/raw/main/dogAndReport.zip} + - ADMIN_EMAIL=${ADMIN_EMAIL:-dspace.admin.dev@dataquest.sk} + - AIPDIR=${AIPDIR:-/tmp/aip-dir} entrypoint: - /bin/bash - '-c' diff --git a/dspace/src/main/docker-compose/db.entities.yml b/dspace/src/main/docker-compose/db.entities.yml index 3480c0df870..d1f17a4e04a 100644 --- a/dspace/src/main/docker-compose/db.entities.yml +++ b/dspace/src/main/docker-compose/db.entities.yml @@ -8,10 +8,10 @@ services: dspacedb: - image: dspace/dspace-postgres-pgcrypto:dspace-7_x-loadsql + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-dspace-7_x}-loadsql" environment: # This SQL is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data - - LOADSQL=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-data.sql + - LOADSQL=${LOADSQL:-https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-data.sql} dspace: ### OVERRIDE default 'entrypoint' in 'docker-compose.yml #### # Ensure that the database is ready BEFORE starting tomcat diff --git a/dspace/src/main/docker-compose/db.restore.yml b/dspace/src/main/docker-compose/db.restore.yml index 09990e67561..9e8e1b5cda3 100644 --- a/dspace/src/main/docker-compose/db.restore.yml +++ b/dspace/src/main/docker-compose/db.restore.yml @@ -12,10 +12,10 @@ # This can be used to restore a "dspacedb" container from a pg_dump, or during upgrade to a new version of PostgreSQL. services: dspacedb: - image: dspace/dspace-postgres-pgcrypto:dspace-7_x-loadsql + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-dspace-7_x}-loadsql" environment: # Location where the dump SQL file will be available on the running container - - LOCALSQL=/tmp/pgdump.sql + - LOCALSQL=${LOCALSQL:-/tmp/pgdump.sql} volumes: # Volume which shares a local SQL file at "./pgdump.sql" to the running container # IF YOUR LOCAL FILE HAS A DIFFERENT NAME (or is in a different location), then change the "./pgdump.sql" diff --git a/dspace/src/main/docker-compose/docker-compose-angular.yml b/dspace/src/main/docker-compose/docker-compose-angular.yml index c9b87c904f1..477d63096e1 100644 --- a/dspace/src/main/docker-compose/docker-compose-angular.yml +++ b/dspace/src/main/docker-compose/docker-compose-angular.yml @@ -18,19 +18,18 @@ services: depends_on: - dspace environment: - DSPACE_UI_SSL: 'false' - DSPACE_UI_HOST: dspace-angular - DSPACE_UI_PORT: '4000' - DSPACE_UI_NAMESPACE: / - DSPACE_REST_SSL: 'false' - DSPACE_REST_HOST: localhost - DSPACE_REST_PORT: 8080 - DSPACE_REST_NAMESPACE: /server - image: dspace/dspace-angular:dspace-7_x + DSPACE_UI_SSL: ${DSPACE_UI_SSL:-false} + DSPACE_UI_HOST: ${DSPACE_UI_HOST:-dspace-angular} + DSPACE_UI_PORT: ${DSPACE_UI_PORT:-4000} + DSPACE_UI_NAMESPACE: ${DSPACE_UI_NAMESPACE:-/} + DSPACE_UI_BASEURL: ${DSPACE_UI_BASEURL:-http://localhost:4000} + DSPACE_REST_SSL: ${DSPACE_REST_SSL:-false} + DSPACE_REST_HOST: ${DSPACE_REST_HOST:-localhost} + DSPACE_REST_PORT: ${DSPACE_REST_PORT:-8080} + DSPACE_REST_NAMESPACE: ${DSPACE_REST_NAMESPACE:-/server} + # Ensure SSR can use the 'dspace' Docker image directly (see docker-compose-rest.yml) + DSPACE_REST_SSRBASEURL: ${DSPACE_REST_SSRBASEURL:-http://dspace:8080/server} + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-angular:${DSPACE_VER:-dspace-7_x-dist}" ports: - published: 4000 target: 4000 - - published: 9876 - target: 9876 - stdin_open: true - tty: true diff --git a/dspace/src/main/docker-compose/docker-compose-shibboleth.yml b/dspace/src/main/docker-compose/docker-compose-shibboleth.yml index f7fb2dcbd1a..1290fbc1e90 100644 --- a/dspace/src/main/docker-compose/docker-compose-shibboleth.yml +++ b/dspace/src/main/docker-compose/docker-compose-shibboleth.yml @@ -21,7 +21,7 @@ services: container_name: dspace-shibboleth depends_on: - dspace - image: dspace/dspace-shibboleth + image: "${DOCKER_REGISTRY:-docker.io}/${DOCKER_OWNER:-dspace}/dspace-shibboleth" build: # Must be relative to root, so that it can be built alongside [src]/docker-compose.yml context: ./dspace/src/main/docker/dspace-shibboleth @@ -30,8 +30,6 @@ services: target: 80 - published: 443 target: 443 - stdin_open: true - tty: true environment: # Default to using "localhost" for Apache & Shibboleth # However, you can override this via the "DSPACE_HOSTNAME" environment variable. diff --git a/pom.xml b/pom.xml index c7ddaffa531..69c1465cfcc 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ org.dspace dspace-parent pom - 7.6.5 + 7.6.7 DSpace Parent Project DSpace open source software is a turnkey institutional repository application. @@ -24,31 +24,32 @@ 5.7.14 5.6.15.Final 6.2.5.Final - 42.7.7 + 42.7.11 8.11.4 - 3.10.8 + 3.11.1 2.31.0 - - 2.19.1 - 2.19.1 + + 2.21.2 + 2.21 1.3.2 2.3.1 2.3.9 1.1.1 9.4.58.v20250814 - 2.25.2 - 3.0.5 + 2.25.4 + 3.0.7 1.19.0 1.7.36 - 3.2.3 + 3.3.0 + 1.81 - - 2.9.0 + 2.10.0 7.9 @@ -58,7 +59,7 @@ https://jena.apache.org/documentation/migrate_jena2_jena3.html --> 2.13.0 - 2.47 + 2.48 UTF-8 @@ -89,7 +90,7 @@ org.apache.maven.plugins maven-enforcer-plugin - 3.5.0 + 3.6.2 enforce-java @@ -140,7 +141,7 @@ org.apache.maven.plugins maven-compiler-plugin - 3.14.0 + 3.15.0 11 @@ -174,7 +175,7 @@ org.apache.maven.plugins maven-jar-plugin - 3.4.2 + 3.5.0 @@ -188,7 +189,7 @@ org.apache.maven.plugins maven-war-plugin - 3.4.0 + 3.5.1 false @@ -295,7 +296,7 @@ com.github.spotbugs spotbugs-maven-plugin - 4.9.3.0 + 4.9.8.3 Max Low @@ -305,7 +306,7 @@ com.github.spotbugs spotbugs - 4.9.3 + 4.9.8 @@ -335,17 +336,17 @@ maven-assembly-plugin - 3.7.1 + 3.8.0 org.apache.maven.plugins maven-dependency-plugin - 3.8.1 + 3.10.0 org.apache.maven.plugins maven-resources-plugin - 3.3.1 + 3.5.0 @@ -357,13 +358,13 @@ org.sonatype.central central-publishing-maven-plugin - 0.8.0 + 0.10.0 org.apache.maven.plugins maven-javadoc-plugin - 3.11.2 + 3.12.0 false @@ -373,7 +374,7 @@ org.apache.maven.plugins maven-source-plugin - 3.3.1 + 3.4.0 @@ -392,7 +393,7 @@ org.jacoco jacoco-maven-plugin - 0.8.13 + 0.8.14 @@ -472,7 +473,7 @@ org.codehaus.mojo xml-maven-plugin - 1.1.0 + 1.2.1 validate-ALL-xml-and-xsl @@ -680,7 +681,7 @@ org.codehaus.mojo license-maven-plugin - 2.5.0 + 2.7.1 false @@ -875,14 +876,14 @@ org.dspace dspace-rest - 7.6.5 + 7.6.7 jar classes org.dspace dspace-rest - 7.6.5 + 7.6.7 war @@ -1031,69 +1032,69 @@ org.dspace dspace-api - 7.6.5 + 7.6.7 org.dspace dspace-api test-jar - 7.6.5 + 7.6.7 test org.dspace.modules additions - 7.6.5 + 7.6.7 org.dspace dspace-sword - 7.6.5 + 7.6.7 org.dspace dspace-swordv2 - 7.6.5 + 7.6.7 org.dspace dspace-oai - 7.6.5 + 7.6.7 org.dspace dspace-services - 7.6.5 + 7.6.7 org.dspace dspace-server-webapp test-jar - 7.6.5 + 7.6.7 test org.dspace dspace-rdf - 7.6.5 + 7.6.7 org.dspace dspace-iiif - 7.6.5 + 7.6.7 org.dspace dspace-server-webapp - 7.6.5 + 7.6.7 jar classes org.dspace dspace-server-webapp - 7.6.5 + 7.6.7 war @@ -1142,11 +1143,21 @@ ${hibernate-validator.version} - + org.jboss.logging jboss-logging - 3.6.1.Final + 3.4.3.Final @@ -1318,13 +1329,13 @@ com.healthmarketscience.jackcess jackcess - 4.0.8 + 4.0.10 org.apache.james apache-mime4j-core - 0.8.12 + 0.8.14 @@ -1358,7 +1369,7 @@ org.apache.ant ant - 1.10.15 + 1.10.17 org.apache.jena @@ -1473,12 +1484,12 @@ commons-cli commons-cli - 1.9.0 + 1.11.0 commons-codec commons-codec - 1.18.0 + 1.22.0 org.apache.commons @@ -1488,54 +1499,54 @@ org.apache.commons commons-configuration2 - 2.12.0 + 2.15.0 org.apache.commons commons-dbcp2 - 2.13.0 + 2.14.0 commons-io commons-io - 2.19.0 + 2.22.0 org.apache.commons commons-lang3 - 3.17.0 + 3.20.0 commons-logging commons-logging - 1.3.5 + 1.3.6 org.apache.commons commons-compress - 1.27.1 + 1.28.0 org.apache.commons commons-pool2 - 2.12.1 + 2.13.1 org.apache.commons commons-text - 1.13.1 + 1.15.0 commons-validator commons-validator - 1.9.0 + 1.10.1 joda-time joda-time - 2.14.0 + 2.14.1 com.sun.mail @@ -1552,7 +1563,7 @@ jaxen jaxen - 2.0.0 + 2.0.1 org.jdom @@ -1684,7 +1695,7 @@ com.h2database h2 - 2.3.232 + 2.4.240 test @@ -1701,7 +1712,7 @@ com.google.http-client google-http-client - 1.47.0 + 1.47.1 com.google.errorprone @@ -1718,12 +1729,12 @@ io.grpc grpc-context - 1.73.0 + 1.80.0 com.google.http-client google-http-client-jackson2 - 1.47.0 + 1.47.1 jackson-core @@ -1745,7 +1756,7 @@ com.google.http-client google-http-client-gson - 1.47.0 + 1.47.1 com.squareup.okhttp3 @@ -1771,12 +1782,12 @@ com.fasterxml classmate - 1.7.0 + 1.7.3 com.fasterxml.jackson.core jackson-annotations - ${jackson.version} + ${jackson-annotations.version} com.fasterxml.jackson.core @@ -1786,14 +1797,14 @@ com.fasterxml.jackson.core jackson-databind - ${jackson-databind.version} + ${jackson.version} com.google.guava guava - 32.1.3-jre + 33.6.0-jre - + org.checkerframework checker-qual @@ -1803,7 +1814,7 @@ xom xom - 1.3.9 + 1.4.0 @@ -1840,7 +1851,7 @@ net.minidev json-smart - 2.5.2 + 2.6.0 @@ -1944,7 +1955,7 @@ scm:git:git@github.com:DSpace/DSpace.git scm:git:git@github.com:DSpace/DSpace.git https://github.com/DSpace/DSpace - dspace-7.6.5 + dspace-7.6.7