diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index f96f72466d..46096cf89d 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -12,42 +12,6 @@ jobs: runs-on: ubuntu-latest env: MAVEN_CLI_OPTS: "--batch-mode --errors --fail-at-end --show-version" - services: - zookeeper: - image: zookeeper:3.9.1-jre-17 - ports: - - 2181:2181 - solr: - image: solr:9 - ports: - - 8983:8983 - env: - ZK_HOST: zookeeper:2181 - options: -e cloud - postgres: - image: postgres:17 - ports: - - 5432:5432 - env: - POSTGRES_USER: admin - POSTGRES_PASSWORD: roda - POSTGRES_DB: roda_core_db - mailhog: - image: mailhog/mailhog:v1.0.1 - ports: - - 1025:1025 - openldap: - image: docker.io/bitnamilegacy/openldap:2.6 - ports: - - 1389:1389 - - 1636:1636 - env: - BITNAMI_DEBUG: true - LDAP_ROOT: dc=roda,dc=org - LDAP_SKIP_DEFAULT_TREE: yes - LDAP_ADMIN_USERNAME: admin - LDAP_ADMIN_PASSWORD: roda - LDAP_EXTRA_SCHEMAS: cosine,inetorgperson,nis,pbkdf2 steps: - uses: actions/checkout@v6 @@ -68,31 +32,13 @@ jobs: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} restore-keys: ${{ runner.os }}-maven- - - name: Set up GO - uses: actions/setup-go@v6 - with: - go-version: '^1.19.5' - check-latest: true - - name: Install siegfried - run: | - go install github.com/richardlehane/siegfried/cmd/sf@latest - sf -update - - name: Install clamdscan mock - run: | - sudo cp .github/workflows/bin/clamscan /usr/bin/clamscan - sudo cp .github/workflows/bin/clamscan /usr/bin/clamdscan - sudo chmod a+rx /usr/bin/clamscan /usr/bin/clamdscan + - name: Install clamdscan + run: sudo apt-get install clamdscan -y - name: Test run: mvn $MAVEN_CLI_OPTS -Dtestng.groups="travis" -Denforcer.skip=true clean org.jacoco:jacoco-maven-plugin:prepare-agent clean test env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RODA_CORE_SOLR_TYPE: CLOUD - RODA_CORE_SOLR_CLOUD_URLS: localhost:2181 - SIEGFRIED_MODE: standalone - LDAP_SERVER_URL: ldap://localhost - LDAP_SERVER_PORT: 1389 - SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/roda_core_db - name: Package run: mvn $MAVEN_CLI_OPTS -Dmaven.test.skip=true package diff --git a/.github/workflows/bin/clamscan b/.github/workflows/bin/clamscan deleted file mode 100644 index 6235c0d9a4..0000000000 --- a/.github/workflows/bin/clamscan +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash - -if [[ "$1" = "--version" ]]; then - echo "ClamAV 0.99.2/24925/Wed Sep 12 09:49:57 2018" - exit 0 -fi - -cat <<-EOF -file: OK - ------------ SCAN SUMMARY ----------- -Known viruses: 6643926 -Engine version: 0.99.2 -Scanned directories: 0 -Scanned files: 1 -Infected files: 0 -Data scanned: 0.00 MB -Data read: 0.00 MB (ratio 0.00:1) -Time: 9.170 sec (0 m 9 s) -EOF diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml index 29693b0815..c2960d6890 100644 --- a/.github/workflows/development.yml +++ b/.github/workflows/development.yml @@ -11,42 +11,6 @@ jobs: env: MAVEN_CLI_OPTS: "--batch-mode --errors --fail-at-end --show-version" - services: - zookeeper: - image: zookeeper:3.9.1-jre-17 - ports: - - 2181:2181 - solr: - image: solr:9 - ports: - - 8983:8983 - env: - ZK_HOST: zookeeper:2181 - options: -e cloud - mailhog: - image: mailhog/mailhog:v1.0.1 - ports: - - 1025:1025 - postgres: - image: postgres:17 - ports: - - 5432:5432 - env: - POSTGRES_USER: admin - POSTGRES_PASSWORD: roda - POSTGRES_DB: roda_core_db - openldap: - image: docker.io/bitnamilegacy/openldap:2.6 - ports: - - 1389:1389 - - 1636:1636 - env: - BITNAMI_DEBUG: true - LDAP_ROOT: dc=roda,dc=org - LDAP_SKIP_DEFAULT_TREE: yes - LDAP_ADMIN_USERNAME: admin - LDAP_ADMIN_PASSWORD: roda - LDAP_EXTRA_SCHEMAS: cosine,inetorgperson,nis,pbkdf2 steps: - uses: actions/checkout@v6 with: @@ -67,27 +31,12 @@ jobs: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} restore-keys: ${{ runner.os }}-maven- - - name: Set up GO - uses: actions/setup-go@v6 - with: - go-version: '^1.22.1' - check-latest: true - - name: Install siegfried - run: | - go install github.com/richardlehane/siegfried/cmd/sf@latest - sf -update - - name: Install clamdscan mock - run: | - sudo cp .github/workflows/bin/clamscan /usr/bin/clamscan - sudo cp .github/workflows/bin/clamscan /usr/bin/clamdscan - sudo chmod a+rx /usr/bin/clamscan /usr/bin/clamdscan + - name: Install clamdscan + run: sudo apt-get install clamdscan -y - name: Run tests & install - run: - mvn $MAVEN_CLI_OPTS -Dtestng.groups="travis" -Denforcer.skip=true clean org.jacoco:jacoco-maven-plugin:prepare-agent install + run: mvn $MAVEN_CLI_OPTS -Dtestng.groups="travis" -Denforcer.skip=true clean org.jacoco:jacoco-maven-plugin:prepare-agent install env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RODA_CORE_SOLR_TYPE: CLOUD - RODA_CORE_SOLR_CLOUD_URLS: localhost:2181 - name: Deploy to GitHub packages run: mvn $MAVEN_CLI_OPTS clean deploy -Dmaven.test.skip=true -Pcore env: @@ -97,20 +46,20 @@ jobs: mkdir -p docker/target cp -r roda-ui/roda-wui/target/roda-wui-$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout).jar docker/target - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to DockerHub container registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Build docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: push: true context: docker diff --git a/.github/workflows/latest.yml b/.github/workflows/latest.yml index eaa3006504..8323b5b06c 100644 --- a/.github/workflows/latest.yml +++ b/.github/workflows/latest.yml @@ -1,4 +1,4 @@ -name: 'Latest' +name: "Latest" on: push: @@ -11,43 +11,6 @@ jobs: env: MAVEN_CLI_OPTS: "--batch-mode --errors --fail-at-end --show-version" - services: - zookeeper: - image: zookeeper:3.9.1-jre-17 - ports: - - 2181:2181 - solr: - image: solr:9 - ports: - - 8983:8983 - env: - ZK_HOST: zookeeper:2181 - options: -e cloud - postgres: - image: postgres:17 - ports: - - 5432:5432 - env: - POSTGRES_USER: admin - POSTGRES_PASSWORD: roda - POSTGRES_DB: roda_core_db - mailhog: - image: mailhog/mailhog:v1.0.1 - ports: - - 1025:1025 - openldap: - image: docker.io/bitnamilegacy/openldap:2.6 - ports: - - 1389:1389 - - 1636:1636 - env: - BITNAMI_DEBUG: true - LDAP_ROOT: dc=roda,dc=org - LDAP_SKIP_DEFAULT_TREE: yes - LDAP_ADMIN_USERNAME: admin - LDAP_ADMIN_PASSWORD: roda - LDAP_EXTRA_SCHEMAS: cosine,inetorgperson,nis,pbkdf2 - steps: - uses: actions/checkout@v6 with: @@ -66,23 +29,10 @@ jobs: with: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} - - name: Set up GO - uses: actions/setup-go@v6 - with: - go-version: '^1.22.1' - check-latest: true - - name: Install siegfried - run: | - go install github.com/richardlehane/siegfried/cmd/sf@latest - sf -update - - name: Install clamdscan mock - run: | - sudo cp .github/workflows/bin/clamscan /usr/bin/clamscan - sudo cp .github/workflows/bin/clamscan /usr/bin/clamdscan - sudo chmod a+rx /usr/bin/clamscan /usr/bin/clamdscan + - name: Install clamdscan + run: sudo apt-get install clamdscan -y - name: Run tests & install - run: - mvn $MAVEN_CLI_OPTS -Dtestng.groups="travis" -Denforcer.skip=true clean org.jacoco:jacoco-maven-plugin:prepare-agent install + run: mvn $MAVEN_CLI_OPTS -Dtestng.groups="travis" -Denforcer.skip=true clean org.jacoco:jacoco-maven-plugin:prepare-agent install env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RODA_CORE_SOLR_TYPE: CLOUD @@ -100,20 +50,20 @@ jobs: mkdir -p docker/target cp -r roda-ui/roda-wui/target/roda-wui-$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout).jar docker/target - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to DockerHub container registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Build docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: push: true context: docker @@ -121,7 +71,7 @@ jobs: ghcr.io/keeps/roda:latest keeps/roda:latest sbom: true - provenance: mode=max + provenance: mode=max - name: Trigger KEEPS GitLab CI/CD run: | curl -X POST \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32638c744a..14a6001604 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: 'Release' +name: "Release" on: push: @@ -13,43 +13,6 @@ jobs: env: MAVEN_CLI_OPTS: "--batch-mode --errors --fail-at-end --show-version" - services: - zookeeper: - image: zookeeper:3.9.1-jre-17 - ports: - - 2181:2181 - solr: - image: solr:9 - ports: - - 8983:8983 - env: - ZK_HOST: zookeeper:2181 - options: -e cloud - postgres: - image: postgres:17 - ports: - - 5432:5432 - env: - POSTGRES_USER: admin - POSTGRES_PASSWORD: roda - POSTGRES_DB: roda_core_db - mailhog: - image: mailhog/mailhog:v1.0.1 - ports: - - 1025:1025 - openldap: - image: docker.io/bitnamilegacy/openldap:2.6 - ports: - - 1389:1389 - - 1636:1636 - env: - BITNAMI_DEBUG: true - LDAP_ROOT: dc=roda,dc=org - LDAP_SKIP_DEFAULT_TREE: yes - LDAP_ADMIN_USERNAME: admin - LDAP_ADMIN_PASSWORD: roda - LDAP_EXTRA_SCHEMAS: cosine,inetorgperson,nis,pbkdf2 - steps: - uses: actions/checkout@v6 with: @@ -69,25 +32,12 @@ jobs: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} restore-keys: ${{ runner.os }}-maven- - - name: Set up GO - uses: actions/setup-go@v6 - with: - go-version: '^1.22.1' - check-latest: true - - name: Install siegfried - run: | - go install github.com/richardlehane/siegfried/cmd/sf@latest - sf -update - - name: Install clamdscan mock - run: | - sudo cp .github/workflows/bin/clamscan /usr/bin/clamscan - sudo cp .github/workflows/bin/clamscan /usr/bin/clamdscan - sudo chmod a+rx /usr/bin/clamscan /usr/bin/clamdscan + - name: Install clamdscan + run: sudo apt-get install clamdscan -y - name: Get release version run: echo "release_version=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_ENV - name: Run tests & install - run: - mvn $MAVEN_CLI_OPTS -Dtestng.groups="travis" -Denforcer.skip=true clean org.jacoco:jacoco-maven-plugin:prepare-agent install + run: mvn $MAVEN_CLI_OPTS -Dtestng.groups="travis" -Denforcer.skip=true clean org.jacoco:jacoco-maven-plugin:prepare-agent install env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RODA_CORE_SOLR_TYPE: CLOUD @@ -106,7 +56,7 @@ jobs: cp -r roda-ui/roda-wui/target/roda-wui-${{ env.release_version }}.jar docker/target - name: Docker meta id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: | ghcr.io/keeps/roda @@ -118,20 +68,20 @@ jobs: flavor: | latest=false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to DockerHub container registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Build docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: push: true context: docker @@ -149,16 +99,16 @@ jobs: draft: true body: | #### New features - + #### Enhancements - + #### Bug fixes - + #### Security - Several dependency major upgrades to fix security vulnerabilities - + --- - + To try out this version, check the [install instructions](https://github.com/keeps/roda/blob/master/deploys/standalone/README.md). - name: Trigger KEEPS GitLab CI/CD run: | diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 64c5b5b292..9047c8cefb 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -1,4 +1,4 @@ -name: 'Staging' +name: "Staging" on: push: @@ -11,43 +11,6 @@ jobs: env: MAVEN_CLI_OPTS: "--batch-mode --errors --fail-at-end --show-version" - services: - zookeeper: - image: zookeeper:3.9.1-jre-17 - ports: - - 2181:2181 - solr: - image: solr:9 - ports: - - 8983:8983 - env: - ZK_HOST: zookeeper:2181 - options: -e cloud - postgres: - image: postgres:17 - ports: - - 5432:5432 - env: - POSTGRES_USER: admin - POSTGRES_PASSWORD: roda - POSTGRES_DB: roda_core_db - mailhog: - image: mailhog/mailhog:v1.0.1 - ports: - - 1025:1025 - openldap: - image: docker.io/bitnamilegacy/openldap:2.6 - ports: - - 1389:1389 - - 1636:1636 - env: - BITNAMI_DEBUG: true - LDAP_ROOT: dc=roda,dc=org - LDAP_SKIP_DEFAULT_TREE: yes - LDAP_ADMIN_USERNAME: admin - LDAP_ADMIN_PASSWORD: roda - LDAP_EXTRA_SCHEMAS: cosine,inetorgperson,nis,pbkdf2 - steps: - uses: actions/checkout@v6 with: @@ -67,50 +30,31 @@ jobs: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} restore-keys: ${{ runner.os }}-maven- - - name: Set up GO - uses: actions/setup-go@v6 - with: - go-version: '^1.22.1' - check-latest: true - - name: Install siegfried - run: | - go install github.com/richardlehane/siegfried/cmd/sf@latest - sf -update - - name: Install clamdscan mock - run: | - sudo cp .github/workflows/bin/clamscan /usr/bin/clamscan - sudo cp .github/workflows/bin/clamscan /usr/bin/clamdscan - sudo chmod a+rx /usr/bin/clamscan /usr/bin/clamdscan + - name: Install clamdscan + run: sudo apt-get install clamdscan -y - name: Run tests & install - run: - mvn $MAVEN_CLI_OPTS -Dtestng.groups="travis" -Denforcer.skip=true clean org.jacoco:jacoco-maven-plugin:prepare-agent install + run: mvn $MAVEN_CLI_OPTS -Dtestng.groups="travis" -Denforcer.skip=true clean org.jacoco:jacoco-maven-plugin:prepare-agent install env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RODA_CORE_SOLR_TYPE: CLOUD - RODA_CORE_SOLR_CLOUD_URLS: localhost:2181 - SIEGFRIED_MODE: standalone - LDAP_SERVER_URL: ldap://localhost - LDAP_SERVER_PORT: 1389 - SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/roda_core_db - name: Copy target to docker context run: | mkdir -p docker/target cp -r roda-ui/roda-wui/target/roda-wui-$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout).jar docker/target - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to GitHub container registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to DockerHub container registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Build & Push docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: push: true context: docker diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..23f3a88b1e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,513 @@ +# CLAUDE.md — RODA Codebase Guide for AI Assistants + +## Project Overview + +RODA (Repository of Authentic Digital Records) is an enterprise-grade **digital preservation repository** implementing the OAIS reference model. It handles ingestion, storage, indexing, and access to digital archival content using internationally recognized preservation standards (METS, EAD, Dublin Core, PREMIS, E-ARK). + +**Current Version:** 6.1.0-SNAPSHOT +**License:** Apache 2.0 +**Package Namespace:** `org.roda.*` + +### Core Technology Stack + +| Component | Technology | Version | +|-----------|-----------|---------| +| Language | Java | 21 (Oracle JDK) | +| Build | Apache Maven | 3.8.6+ | +| Backend | Spring Boot | 3.5.x | +| Web UI | GWT | 2.12.2 | +| Search/Index | Apache Solr | 9.10.0 | +| Database | PostgreSQL | 17 | +| Actors | Pekko (Akka replacement) | 1.4.0 | +| Serialization | Jackson | 2.20.1 | + +--- + +## Repository Structure + +``` +roda/ +├── pom.xml # Root Maven POM — dependency management, profiles +├── code-style/ +│ ├── checkstyle.xml # Enforced style rules +│ └── eclipse_formatter.xml # Java formatter (required for IntelliJ) +├── roda-common/ +│ ├── roda-common-data/ # Shared POJOs: AIP, DIP, Representation, etc. +│ └── roda-common-utils/ # Shared utility classes +├── roda-core/ +│ ├── roda-core/ # Core business logic +│ │ └── src/main/java/org/roda/core/ +│ │ ├── common/ # PREMIS, email, Handlebars templates +│ │ ├── config/ # Spring Boot configuration +│ │ ├── entity/ # JPA/Hibernate entities +│ │ ├── events/ # Async event system +│ │ ├── index/ # Solr indexing services & filters +│ │ ├── migration/ # Schema/data migration logic +│ │ ├── model/ # CRUD for RODA objects (AIPs, DIPs) +│ │ ├── plugins/ # Preservation action plugin framework +│ │ ├── protocols/ # File transfer protocols +│ │ ├── repository/ # Repository service interfaces +│ │ ├── security/ # Auth, LDAP, permissions +│ │ ├── storage/ # Filesystem/cloud storage abstraction +│ │ └── transaction/ # Transaction logging +│ └── roda-core-tests/ # Test helpers, TestsHelper, CorporaConstants +├── roda-ui/ +│ └── roda-wui/ # GWT web app + Spring Boot REST API +│ └── src/main/java/org/roda/wui/ +│ ├── api/v1/ # Legacy REST endpoints +│ ├── api/v2/ # Current REST endpoints (OpenAPI) +│ ├── client/ # GWT client-side components +│ ├── common/ # Shared UI utilities +│ ├── config/ # Spring configuration +│ ├── filter/ # Security filters +│ ├── security/ # Authentication handlers +│ └── servlets/ # Custom servlet implementations +├── deploys/ +│ └── standalone/ +│ ├── docker-compose.yaml # Production-like setup +│ └── docker-compose-dev.yaml # Development setup +├── docker/ # Docker image build files +│ └── Dockerfile # eclipse-temurin:21-jre-jammy base +├── dev/ +│ └── codeserver/ # GWT codeserver config for hot reload +├── scripts/ # Release and utility scripts +├── documentation/ # 100+ Markdown docs (multi-language) +├── openapi.json # REST API specification +└── .github/workflows/ # CI/CD pipelines +``` + +--- + +## Development Setup + +### Prerequisites + +1. **Java 21** (Oracle JDK) — strictly required for compilation +2. **Maven 3.8.6+** — build tool +3. **Docker & Docker Compose** — for running Solr, PostgreSQL, LDAP, etc. +4. **GitHub account with PAT** — required for GitHub Packages dependency resolution + +**Configure Maven for GitHub Packages** (`~/.m2/settings.xml`): + +The easiest way is to set the environment variables and run the provided script: +```bash +export GITHUB_MAVEN_USER=YOUR_GITHUB_USERNAME +export GITHUB_MAVEN_PASSWORD=YOUR_GITHUB_PAT +./scripts/setup_maven_settings.sh +``` + +Alternatively, create `~/.m2/settings.xml` manually: +```xml + + + + github + YOUR_GITHUB_USERNAME + YOUR_GITHUB_PAT + + + +``` +The PAT must have `read:packages` permission. Without this, the build will fail to resolve dependencies. + +### Starting Development Dependencies + +```bash +# Create required data directories +mkdir -p $HOME/.roda/data/{storage,staging-storage} + +# Start all services (Solr, ZooKeeper, PostgreSQL, ClamAV, Siegfried, OpenLDAP, MailPit) +docker compose -f deploys/standalone/docker-compose-dev.yaml up -d +``` + +Services and ports: +- ZooKeeper: `2181` +- Apache Solr: `8983` +- PostgreSQL: `5432` +- OpenLDAP: `1389` +- MailPit (SMTP): `1025` +- Swagger UI: `8088` + +--- + +## Build Commands + +```bash +# Full build with tests +mvn clean package + +# Build without tests (faster) +mvn clean package -Dmaven.test.skip=true + +# Build core modules only (skip UI) +mvn clean package -Pcore + +# Install core to local Maven repo +mvn install -Pcore -DskipTests + +# First-time GWT compile (slow, ~5-10 min) +mvn -pl roda-ui/roda-wui -am gwt:compile -Pdebug-main -Dscope.gwt-dev=compile +``` + +### Build Profiles + +| Profile | Purpose | +|---------|---------| +| _(default)_ | All modules | +| `-Pcore` | Core modules only (faster, skips GWT) | +| `-Pdebug-main` | GWT development/debugging mode | +| `-Proda-core-jar` | Produces shaded JAR | +| `-Proda-core-jar-docker` | Docker-ready shaded JAR | + +### Typical Build Times + +- Full build with tests: ~10–15 minutes +- Build without tests: ~5–8 minutes +- Tests only: ~8–12 minutes +- First GWT compile: ~5–10 minutes + +--- + +## Running the Application Locally + +```bash +# 1. Install core to local repo +mvn install -Pcore -DskipTests + +# 2. Start Spring Boot application +mvn -pl roda-ui/roda-wui -am spring-boot:run -Pdebug-main + +# 3. (Optional) Start GWT codeserver for hot reload in a separate terminal +mvn -f dev/codeserver gwt:codeserver -DrodaPath=$(pwd) +``` + +- App available at: `http://localhost:8080` +- GWT codeserver UI: `http://127.0.0.1:9876/` +- Activate GWT dev mode: Open RODA in browser, click "Dev Mode On" bookmark + +--- + +## Testing + +### Test Framework + +- **TestNG** (not JUnit) for all Java tests +- Tag CI tests with `@Test(groups = "travis")` + +### Running Tests + +```bash +# All tests (requires Docker services running) +mvn clean test + +# CI subset only (faster) +mvn -Dtestng.groups="travis" -Denforcer.skip=true clean org.jacoco:jacoco-maven-plugin:prepare-agent test + +# Skip tests +mvn clean package -Dmaven.test.skip=true +``` + +### Required Environment Variables (for tests matching CI) + +``` +RODA_CORE_SOLR_TYPE=CLOUD +SIEGFRIED_MODE=standalone +``` + +See `.github/workflows/CI.yml` for the full CI test environment configuration. + +### Key Test Classes + +- `org.roda.core.TestsHelper` — common test utilities +- `org.roda.core.CorporaConstants` — test data constants +- `ModelServiceTest`, `IndexServiceTest`, `StorageServiceTest` +- `IngestPluginTests`, `DisposalTests`, `PermissionsTest` + +Tests live in: +- `roda-core/roda-core-tests/src/main/java/org/roda/core/` +- `roda-ui/roda-wui/src/test/` + +--- + +## Code Style & Conventions + +### Java Formatting + +- Formatter: **Eclipse Code Formatter** (`code-style/eclipse_formatter.xml`) +- Linter: **Checkstyle** (`code-style/checkstyle.xml`) — enforced during Maven build +- Import order: `java;javax;org;com;` +- Wildcard imports: **disabled** (threshold set to 9999 classes) + +### IntelliJ IDEA Setup + +1. Install plugin: "Adapter for Eclipse Code Formatter" +2. Point formatter at `code-style/eclipse_formatter.xml` +3. Set import thresholds (class count: 9999, static: 9999) +4. Format with: `Code > Reformat File...` → "Only VCS changed text" + +### Naming & Package Conventions + +- Root package: `org.roda` +- Module packages: `org.roda.core.*`, `org.roda.wui.*`, `org.roda.common.*` +- Model objects (POJOs) live in `roda-common-data` +- REST endpoints follow versioned paths: `/api/v1/` (legacy) and `/api/v2/` (current) + +--- + +## Architecture Overview + +### Layered Architecture + +``` +GWT Web UI (client-side) + ↕ REST API (Spring MVC, /api/v2/) +Spring Boot Application (roda-wui) + ↕ Service interfaces +Core Services (roda-core) + ├── ModelService — CRUD for AIPs, DIPs, Representations + ├── IndexService — Solr-backed search & filtering + ├── StorageService — Filesystem/cloud storage abstraction + └── PluginService — Preservation action orchestration + ↕ Pekko actors (async jobs) +Infrastructure + ├── PostgreSQL — Transactions, JPA entities + ├── Apache Solr — Indexing & search (Cloud mode) + └── LDAP/CAS — Authentication +``` + +### Key Design Patterns + +- **Factory pattern:** `RodaCoreFactory` bootstraps and provides all core services +- **Observer pattern:** Index services observe model changes for automatic re-indexing +- **Plugin architecture:** Extensible plugins for preservation workflows +- **Storage abstraction:** OpenStack Swift-inspired storage interface +- **Event-driven:** Pekko actors for async, parallel preservation tasks +- **Transactional wrapper:** `TransactionalModelService` adds transaction logging + +### OAIS Information Packages + +RODA implements the OAIS standard: +- **SIP** — Submission Information Package (ingest) +- **AIP** — Archival Information Package (stored) +- **DIP** — Dissemination Information Package (access) + +--- + +## REST API + +- **v1** (legacy): `/api/v1/` — maintained for backwards compatibility +- **v2** (current): `/api/v2/` — fully documented via OpenAPI +- OpenAPI spec: `openapi.json` and served at `/api/v2/openapi` +- Swagger UI (dev): `http://localhost:8088` + +--- + +## Database + +- **PostgreSQL 17** via Spring Data JPA + Hibernate +- DDL mode: `update` (Hibernate auto-updates schema) +- Default connection: `jdbc:postgresql://localhost:5432/roda_core_db` +- Credentials: `admin` / `roda` (development defaults) +- Schema init: `spring.sql.init.mode=always` + +--- + +## CI/CD + +### Workflows + +| File | Trigger | Purpose | +|------|---------|---------| +| `CI.yml` | Every push | Tests + package build | +| `codeql-analysis.yml` | Schedule | Security scanning | +| `development.yml` | Push to dev branch | Dev deployment | +| `staging.yml` | Push to staging | Staging deployment | +| `release.yml` | Tag push | Production release | + +### CI Test Command + +```bash +mvn -Dtestng.groups="travis" -Denforcer.skip=true \ + clean org.jacoco:jacoco-maven-plugin:prepare-agent test +``` + +--- + +## Release Process + +Before releasing: +```bash +# Security vulnerability check +mvn com.redhat.victims.maven:security-versions:check + +# Check for dependency updates +./scripts/check_versions.sh MINOR +mvn versions:display-dependency-updates +``` + +Release workflow (example: releasing 2.2.0, next 2.3.0): +```bash +./scripts/release.sh 2.2.0 +# Wait for GitHub Actions release.yml to succeed +# Review and publish the GitHub Release +./scripts/update_changelog.sh 2.2.0 +./scripts/prepare_next_version.sh 2.3.0 +``` + +--- + +## Docker + +```bash +# Build local Docker image +cd docker && ./build.sh + +# Production-like local stack +docker compose -f deploys/standalone/docker-compose.yaml up -d + +# Development stack +docker compose -f deploys/standalone/docker-compose-dev.yaml up -d + +# Access shell in running container +docker exec -it CONTAINER_ID /bin/bash +``` + +Base image: `eclipse-temurin:21-jre-jammy` + +--- + +## Multi-Module Build Dependencies + +When modifying a module, rebuild its dependents: + +``` +roda-common-data → roda-common-utils → roda-core → roda-wui +``` + +After changing `roda-core`, run `mvn install -Pcore -DskipTests` before building `roda-wui` to update the local Maven cache. + +--- + +## Internationalization (i18n) + +- Translation files: `roda-ui/roda-wui/src/main/resources/config/i18n/` +- Format: Java `.properties` files +- Managed via Transifex (cloud translation platform) +- Supported languages: English, Portuguese, Swedish, Hungarian, Spanish, Croatian, German (Austria), and more +- See `documentation/Translation_Guide.md` for translation workflow + +--- + +## Key Files for Orientation + +| File | Purpose | +|------|---------| +| `README.md` | Project overview, features, editions | +| `DEV_NOTES.md` | Quick-start for developers | +| `documentation/Developers_Guide.md` | Comprehensive development guide | +| `openapi.json` | REST API specification | +| `pom.xml` | Root Maven config with all dependency versions | +| `.github/workflows/CI.yml` | CI configuration and test environment | +| `deploys/standalone/docker-compose-dev.yaml` | Dev service stack | + +--- + +## Critical Notes for AI Assistants + +1. **GitHub Packages auth is required.** Maven build will fail without a valid `~/.m2/settings.xml` with a GitHub PAT having `read:packages`. + +2. **Always start Docker services before running tests.** Tests use Testcontainers (auto-starts ZooKeeper, Solr, PostgreSQL, LDAP, Mailpit, ClamAV, Siegfried). The Docker daemon must be running. In the Claude Code cloud environment, start it with: `service docker start` (may print an ulimit warning — that is harmless). + +3. **Use the correct Maven profile.** + - Skip UI/GWT: use `-Pcore` + - GWT dev mode: use `-Pdebug-main` + - Never mix profiles incorrectly. + +4. **TestNG, not JUnit.** All test annotations are `org.testng.*`. Group CI tests with `@Test(groups = "travis")`. + +5. **Multi-module dependency order.** Changing `roda-common-data` or `roda-core` requires `mvn install` before building downstream modules. + +6. **GWT compilation is slow.** The first compile takes 5–10 minutes. For active UI development, use the codeserver (`mvn -f dev/codeserver gwt:codeserver`) for hot reload. + +7. **Code formatting is enforced.** Checkstyle runs during every Maven build. Always apply the Eclipse formatter from `code-style/eclipse_formatter.xml` before committing. + +8. **REST API has two versions.** New endpoints go in `/api/v2/`. Do not add business logic to `/api/v1/` (legacy). + +9. **PREMIS metadata is mandatory.** Every preservation action must record a PREMIS event in the AIP's metadata. Follow existing plugin implementations as examples. + +10. **Commit signing.** Commits should be GPG-signed per the project's contribution guidelines. See: https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits + +--- + +## Claude Code Cloud Environment — Quick Reference + +This section captures environment-specific quirks for running in the Claude Code remote container. + +### Docker Setup + +Docker daemon is installed but may not be running at session start: + +```bash +# Check if Docker is running +docker ps + +# If not running, start it (the ulimit warning is harmless): +service docker start + +# Verify +docker ps # should show empty table, not an error +``` + +### Build Commands (Cloud Environment) + +Maven Central access may be blocked by a proxy. Always use **offline mode** (`-o`) or the local repo when possible. The proxy is pre-configured via `JAVA_TOOL_OPTIONS` env var. + +```bash +# Step 1: Build and install core modules (no tests, no GWT) +mvn install -Pcore -DskipTests -Denforcer.skip=true + +# Step 2: Run a single test class to verify (fast validation) +mvn -pl roda-core/roda-core-tests test -Pcore \ + -Dtestng.groups="travis" \ + -Denforcer.skip=true \ + -Dsurefire.suiteXmlFiles=testng-single.xml \ + -o + +# Step 3: Run full CI test suite +mvn -Pcore -Dtestng.groups="travis" -Denforcer.skip=true \ + clean org.jacoco:jacoco-maven-plugin:prepare-agent test +``` + +### Single-Test Shortcut + +`roda-core/roda-core-tests/testng-single.xml` targets only `IndexServiceTest`. Edit the `` element to point at any test class you want to run in isolation. + +### Test Infrastructure (Testcontainers) + +Tests use `TestContainersManager` (singleton) to start containers once per JVM. The `RodaContainersLifecycleListener` triggers it via `testng.xml`. Containers started: + +| Service | Image | +|-------------|---------------------------| +| ZooKeeper | zookeeper:3.9.1-jre-17 | +| Solr | solr:9 | +| PostgreSQL | postgres:17 | +| Mailpit | axllent/mailpit:latest | +| ClamAV | clamav/clamav:1.5.2 | +| Siegfried | keeps/siegfried:v1.11.0 | + +**Important**: On Linux, Solr registers its container IP in ZooKeeper. Bridge network IPs (`172.x.x.x`) are directly routable from the host — no port mapping is needed for the CloudSolrClient to reach Solr live nodes. + +### ZooKeeper / Solr Connection Notes + +- `zkConnectTimeout` defaults to 15 s in SolrJ. If the ZK session is not established within that window, `SolrZkClient` calls `ZooKeeper.close()`, which **hangs indefinitely** (sends CLOSESESSION but has no threads left to receive the response). +- The fix is in `RodaCoreFactory.instantiateSolr()`: `withZkConnectTimeout(300000, MILLISECONDS)` is set on the builder. +- `TestContainersManager` also sets `System.setProperty("zkConnectTimeout", "300000")` as belt-and-suspenders. + +### Pre-PR Checklist + +Before pushing/creating a PR: +1. `service docker start` (if not already running) +2. `mvn install -Pcore -DskipTests -Denforcer.skip=true` — compile all modules +3. Run a targeted single test to validate the change area +4. Run full CI test suite if the change is broad +5. Verify no Checkstyle violations (they are enforced in CI) diff --git a/deploys/standalone/docker-compose-dev.yaml b/deploys/standalone/docker-compose-dev.yaml index ecd695b152..bc4438a515 100644 --- a/deploys/standalone/docker-compose-dev.yaml +++ b/deploys/standalone/docker-compose-dev.yaml @@ -108,6 +108,22 @@ services: volumes: - pg_data:/var/lib/postgresql/data + dbgate: + image: dbgate/dbgate + restart: always + ports: + - 8082:3000 + volumes: + - dbgate-data:/root/.dbgate + environment: + CONNECTIONS: pgsql + LABEL_pgsql: Postgres + SERVER_pgsql: postgres + USER_pgsql: admin + PASSWORD_pgsql: roda + PORT_pgsql: 5432 + ENGINE_pgsql: postgres@dbgate-plugin-postgres + volumes: zookeeper_data: zookeeper_datalog: @@ -115,3 +131,4 @@ volumes: clam_data: siegfried_data: pg_data: + dbgate-data: diff --git a/dev/codeserver/pom.xml b/dev/codeserver/pom.xml index b74284edf6..0848d68670 100644 --- a/dev/codeserver/pom.xml +++ b/dev/codeserver/pom.xml @@ -11,14 +11,6 @@ codeserver - - - github - GitHub Packages - https://maven.pkg.github.com/keeps/* - - - UTF-8 .. @@ -54,7 +46,7 @@ ${roda.version} - com.ekotrope + org.roda-community gwt-completablefuture 1.0.1 @@ -70,15 +62,26 @@ sources - org.fusesource.restygwt + org.roda-community restygwt - 2.2.12-keeps + 2.2.12 jakarta.ws.rs jakarta.ws.rs-api 3.1.0 + + jakarta.persistence + jakarta.persistence-api + 3.1.0 + + + jakarta.persistence + jakarta.persistence-api + 3.1.0 + sources + io.swagger.core.v3 swagger-jaxrs2-jakarta diff --git a/pom.xml b/pom.xml index 22382df4ac..1df3430ffd 100644 --- a/pom.xml +++ b/pom.xml @@ -27,18 +27,6 @@ https://maven.pkg.github.com/keeps/roda - - - github - GitHub Packages - https://maven.pkg.github.com/keeps/* - - - maven-restlet - Public online Restlet repository - https://maven.restlet.talend.com/ - - lfaria @@ -121,20 +109,21 @@ ${user.dir} 21 2.12.2 - 4.0.4 + 4.1.0 2.2.41 2.20.1 - 6.2.11 + 6.2.17 9.10.0 - 1.1.4 + 1.4.0 5.5 - 2.10.0 + 2.11.2 3.2.6 https://roda-community.org all - 3.4.10 + 3.5.12 provided 1.5.25 + 5.18.0 @@ -299,7 +288,7 @@ true - org.project + org.gwtproject gwt-dev @@ -437,6 +426,21 @@ + + com.fasterxml.jackson + jackson-bom + 2.20.0 + import + pom + + + + org.springframework.boot + spring-boot-dependencies + ${springboot.version} + pom + import + jakarta.servlet jakarta.servlet-api @@ -478,21 +482,6 @@ jaxb-runtime 4.0.5 - - com.fasterxml.jackson - jackson-bom - 2.20.0 - import - pom - - - - org.springframework.boot - spring-boot-dependencies - ${springboot.version} - pom - import - org.gwtproject gwt-dev @@ -706,9 +695,9 @@ ${cas.client.version} - org.fusesource.restygwt + org.roda-community restygwt - 2.2.12-keeps + 2.2.12 @@ -760,19 +749,7 @@ org.mockito mockito-core - 5.18.0 - test - - - org.powermock - powermock-api-mockito2 - 2.0.9 - test - - - org.powermock - powermock-module-junit4 - 1.7.4 + ${mockito.version} test @@ -864,6 +841,11 @@ httpclient5 5.6 + + org.apache.httpcomponents.core5 + httpcore5 + 5.4 + com.github.jknack diff --git a/roda-common/roda-common-data/pom.xml b/roda-common/roda-common-data/pom.xml index eda2db2529..294b733b43 100644 --- a/roda-common/roda-common-data/pom.xml +++ b/roda-common/roda-common-data/pom.xml @@ -67,6 +67,10 @@ org.apache.commons commons-lang3 + + jakarta.persistence + jakarta.persistence-api + diff --git a/roda-common/roda-common-data/src/main/java/org/roda/core/data/common/RodaConstants.java b/roda-common/roda-common-data/src/main/java/org/roda/core/data/common/RodaConstants.java index 91c0095905..79c1de10bc 100644 --- a/roda-common/roda-common-data/src/main/java/org/roda/core/data/common/RodaConstants.java +++ b/roda-common/roda-common-data/src/main/java/org/roda/core/data/common/RodaConstants.java @@ -733,6 +733,10 @@ public String toString() { public static final String CONTROLLER_LOCAL_INSTANCE_PARAM = RODA_OBJECT_LOCAL_INSTANCE; public static final String CONTROLLER_LOCAL_INSTANCE_ID_PARAM = "localInstanceId"; public static final String CONTROLLER_ACCESS_KEY_PARAM = RODA_OBJECT_ACCESS_KEY; + public static final String CONTROLLER_ACCESS_KEY_USERNAME_PARAM = "username"; + public static final String CONTROLLER_ACCESS_KEY_ID_PARAM = "accessKeyId"; + public static final String CONTROLLER_ACCESS_KEY_NAME_PARAM = "accessKeyName"; + public static final String CONTROLLER_ACCESS_KEY_EXP_DATE_PARAM = "expirationDate"; public static final String CONTROLLER_ID_OBJECT_PARAM = "transferred_resource_uuid, transferred_resource_path, sip, transferred_resource_original_name"; public static final String CONTROLLER_SIP_PARAM = "sip"; @@ -1010,6 +1014,7 @@ public enum OrchestratorType { public static final String STORAGE_AIP_METADATA_FILENAME = "aip.json"; public static final String STORAGE_DIP_METADATA_FILENAME = "dip.json"; + public static final String STORAGE_METS_FILENAME = "METS.xml"; /* * OTHER METADATA TYPES diff --git a/roda-common/roda-common-data/src/main/java/org/roda/core/data/utils/JsonUtils.java b/roda-common/roda-common-data/src/main/java/org/roda/core/data/utils/JsonUtils.java index 96b6917148..168ce9b48d 100644 --- a/roda-common/roda-common-data/src/main/java/org/roda/core/data/utils/JsonUtils.java +++ b/roda-common/roda-common-data/src/main/java/org/roda/core/data/utils/JsonUtils.java @@ -22,6 +22,7 @@ import java.util.Map; import java.util.Map.Entry; +import com.fasterxml.jackson.core.JsonProcessingException; import org.apache.commons.io.IOUtils; import org.roda.core.data.exceptions.GenericException; import org.roda.core.data.v2.ip.metadata.DescriptiveMetadata; @@ -48,6 +49,11 @@ private JsonUtils() { // do nothing } + public static byte[] toByteArray(Object object) throws JsonProcessingException { + ObjectMapper mapper = new ObjectMapper(); + return mapper.writeValueAsBytes(object); + } + public static T readObjectFromFile(Path jsonFile, Class objectClass) throws GenericException { try (InputStream stream = Files.newInputStream(jsonFile)) { return getObjectFromJson(stream, objectClass); diff --git a/roda-common/roda-common-data/src/main/java/org/roda/core/data/utils/URNUtils.java b/roda-common/roda-common-data/src/main/java/org/roda/core/data/utils/URNUtils.java index da3786dc3e..27947c88fb 100644 --- a/roda-common/roda-common-data/src/main/java/org/roda/core/data/utils/URNUtils.java +++ b/roda-common/roda-common-data/src/main/java/org/roda/core/data/utils/URNUtils.java @@ -13,6 +13,7 @@ import org.roda.core.data.common.RodaConstants; import org.roda.core.data.common.RodaConstants.RODA_TYPE; +import org.roda.core.data.exceptions.RequestNotValidException; import org.roda.core.data.v2.ip.metadata.PreservationMetadata.PreservationMetadataType; public final class URNUtils { @@ -147,14 +148,20 @@ public static String getAgentUsernameFromURN(String urnId) { return retrieveIdWithHours(fields); } - public static boolean verifyInstanceIdentifier(String id, String instanceId) { - if (extractInstanceIdentifierFromId(id).equals(instanceId)) { - return true; + public static String getFileIdFromURN(String urn) throws RequestNotValidException { + String[] fields = urn.split(RodaConstants.URN_SEPARATOR); + if (fields.length == URN_LENGTH_WITH_INSTANCE_IDENTIFIER + || fields.length == URN_LENGTH_WITHOUT_INSTANCE_IDENTIFIER) { + return fields[fields.length - 1]; } else { - return false; + throw new RequestNotValidException("Invalid URN format: " + urn); } } + public static boolean verifyInstanceIdentifier(String id, String instanceId) { + return extractInstanceIdentifierFromId(id).equals(instanceId); + } + public static String extractInstanceIdentifierFromId(String id) { String[] fields = id.split(RodaConstants.URN_SEPARATOR); return fields[URN_INSTANCE_IDENTIFIER_POSITION]; diff --git a/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/ip/AIP.java b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/ip/AIP.java index e102e368bd..4b62405296 100644 --- a/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/ip/AIP.java +++ b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/ip/AIP.java @@ -51,7 +51,15 @@ public class AIP implements IsModelObject, HasId, HasState, HasPermissions, HasD private Boolean ghost = null; private Boolean hasShallowFiles = false; + /** + * @deprecated As of 6.0.2, going to be removed in 7.0.0. + */ private AIPFormat format; + + /** + * @deprecated As of 6.0.2, going to be removed in 7.0.0. + */ + private List relationships; private Date createdOn = null; @@ -267,9 +275,9 @@ public void addDescriptiveMetadata(DescriptiveMetadata descriptiveMetadata) { public void addTechnicalMetadata(TechnicalMetadata technicalMetadata) { if (!technicalMetadata.isFromAIP()) { for (Representation representation : this.representations) { - if (representation.getId().equals(technicalMetadata.getRepresentationId() )) { - representation.addTechnicalMetadata(technicalMetadata); - break; + if (representation.getId().equals(technicalMetadata.getRepresentationId())) { + representation.addTechnicalMetadata(technicalMetadata); + break; } } } diff --git a/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jobs/Job.java b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jobs/Job.java index 1b61b1dd21..1cb406b495 100644 --- a/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jobs/Job.java +++ b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jobs/Job.java @@ -22,14 +22,33 @@ import org.roda.core.data.v2.ip.HasId; import org.roda.core.data.v2.ip.HasInstanceID; import org.roda.core.data.v2.ip.HasInstanceName; +import org.roda.core.data.v2.jpa.JobStatsConverter; +import org.roda.core.data.v2.jpa.JobUserDetailsListConverter; +import org.roda.core.data.v2.jpa.ObjectMapConverter; +import org.roda.core.data.v2.jpa.SelectedItemsConverter; +import org.roda.core.data.v2.jpa.StringListConverter; +import org.roda.core.data.v2.jpa.StringMapConverter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Temporal; +import jakarta.persistence.TemporalType; +import jakarta.persistence.Transient; + /** * @author Hélder Silva */ +@Entity +@Table(name = "jobs") @jakarta.xml.bind.annotation.XmlRootElement(name = RodaConstants.RODA_OBJECT_JOB) @JsonInclude(JsonInclude.Include.ALWAYS) @JsonIgnoreProperties(ignoreUnknown = true) @@ -42,46 +61,79 @@ public enum JOB_STATE { PENDING_APPROVAL, REJECTED, SCHEDULED; } // job identifier + @Id + @Column(name = "id") private String id = null; // job name + @Column(name = "name") private String name = null; // job creator + @Column(name = "username") private String username = null; // job start date + @Column(name = "start_date") + @Temporal(TemporalType.TIMESTAMP) private Date startDate = null; // job end date + @Column(name = "end_date") + @Temporal(TemporalType.TIMESTAMP) private Date endDate = null; // job state + @Enumerated(EnumType.STRING) + @Column(name = "state") private JOB_STATE state = null; // job state details + @Column(name = "state_details", columnDefinition = "TEXT") private String stateDetails = ""; // job instance id + @Column(name = "instance_id") private String instanceId = null; + @Column(name = "job_users_details", columnDefinition = "TEXT") + @Convert(converter = JobUserDetailsListConverter.class) private List jobUsersDetails = new ArrayList<>(); + @Column(name = "instance_name") private String instanceName = null; // job statistics (total source objects, etc.) + @Column(name = "job_stats", columnDefinition = "TEXT") + @Convert(converter = JobStatsConverter.class) JobStats jobStats = new JobStats(); // plugin full class (e.g. org.roda.core.plugins.plugins.base.FixityPlugin) + @Column(name = "plugin") private String plugin = null; // plugin type (e.g. ingest, maintenance, misc, etc.) + @Enumerated(EnumType.STRING) + @Column(name = "plugin_type") private PluginType pluginType = null; // plugin parameters + @Column(name = "plugin_parameters", columnDefinition = "TEXT") + @Convert(converter = StringMapConverter.class) private Map pluginParameters = new HashMap<>(); // objects to act upon (All, None, List, Filter, etc.) + @Column(name = "source_objects", columnDefinition = "TEXT") + @Convert(converter = SelectedItemsConverter.class) private SelectedItems sourceObjects = null; + @Column(name = "outcome_objects_class") private String outcomeObjectsClass = ""; + @Column(name = "attachments_list", columnDefinition = "TEXT") + @Convert(converter = StringListConverter.class) private List attachmentsList = new ArrayList<>(); + @Column(name = "fields", columnDefinition = "TEXT") + @Convert(converter = ObjectMapConverter.class) private Map fields; + @Enumerated(EnumType.STRING) + @Column(name = "priority") private JobPriority priority; + @Enumerated(EnumType.STRING) + @Column(name = "parallelism") private JobParallelism parallelism; public Job() { @@ -112,6 +164,7 @@ public Job(Job job) { this.jobUsersDetails = job.getJobUsersDetails(); } + @Transient @JsonIgnore @Override public int getClassVersion() { diff --git a/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jobs/Report.java b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jobs/Report.java index b8197e7f92..2ed9d6217f 100644 --- a/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jobs/Report.java +++ b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jobs/Report.java @@ -17,11 +17,27 @@ import org.roda.core.data.v2.ip.HasId; import org.roda.core.data.v2.ip.HasInstanceID; import org.roda.core.data.v2.ip.SIPInformation; +import org.roda.core.data.v2.jpa.ReportListConverter; +import org.roda.core.data.v2.jpa.StringListConverter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.Temporal; +import jakarta.persistence.TemporalType; +import jakarta.persistence.Transient; + +@Entity +@Table(name = "job_reports", indexes = {@Index(name = "idx_report_job_id", columnList = "jobId")}) @JsonInclude(JsonInclude.Include.NON_NULL) public class Report implements IsModelObject, HasId, HasInstanceID { @Serial @@ -32,39 +48,74 @@ public class Report implements IsModelObject, HasId, HasInstanceID { public static final String NO_OUTCOME_OBJECT_ID = "NO_OUTCOME_ID"; public static final String NO_OUTCOME_OBJECT_CLASS = "NO_OUTCOME_CLASS"; + @Id + @Column(name = "id") private String id = ""; + @Column(name = "job_id") private String jobId = ""; + @Column(name = "source_object_id") private String sourceObjectId = NO_SOURCE_OBJECT_ID; + @Column(name = "source_object_class") private String sourceObjectClass = NO_SOURCE_OBJECT_CLASS; + @Column(name = "source_object_original_ids", columnDefinition = "TEXT") + @Convert(converter = StringListConverter.class) private List sourceObjectOriginalIds = new ArrayList<>(); + @Column(name = "source_object_original_name") private String sourceObjectOriginalName = ""; + @Column(name = "outcome_object_id") private String outcomeObjectId = NO_OUTCOME_OBJECT_ID; + @Column(name = "outcome_object_class") private String outcomeObjectClass = NO_OUTCOME_OBJECT_CLASS; + @Enumerated(EnumType.STRING) + @Column(name = "outcome_object_state") private AIPState outcomeObjectState = AIPState.getDefault(); + @Column(name = "title") private String title = ""; + @Column(name = "date_created") + @Temporal(TemporalType.TIMESTAMP) private Date dateCreated; + @Column(name = "date_updated") + @Temporal(TemporalType.TIMESTAMP) private Date dateUpdated; + @Column(name = "ingest_type") private String ingestType = ""; + @Column(name = "completion_percentage") private Integer completionPercentage = 0; + @Column(name = "steps_completed") private Integer stepsCompleted = 0; + @Column(name = "total_steps") private Integer totalSteps = 0; + @Column(name = "plugin") private String plugin = ""; + @Column(name = "plugin_name") private String pluginName = ""; + @Column(name = "plugin_version") private String pluginVersion = ""; + @Enumerated(EnumType.STRING) + @Column(name = "plugin_state") private PluginState pluginState = PluginState.RUNNING; + @Column(name = "plugin_is_mandatory") private Boolean pluginIsMandatory = true; + @Column(name = "plugin_details", columnDefinition = "TEXT") private String pluginDetails = ""; + @Column(name = "html_plugin_details") private boolean htmlPluginDetails = false; + @Column(name = "instance_id") private String instanceId = null; + @Column(name = "transaction_id") private String transactionId = null; + @Transient @JsonIgnore private SIPInformation sipInformation = new SIPInformation(); + @Column(name = "reports", columnDefinition = "TEXT") + @Convert(converter = ReportListConverter.class) private List reports = new ArrayList<>(); + @Column(name = "line_separator") private String lineSeparator = ""; public Report() { @@ -106,6 +157,7 @@ public Report(Report report) { this.transactionId = report.getTransactionId(); } + @Transient @JsonIgnore @Override public int getClassVersion() { diff --git a/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/JobStatsConverter.java b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/JobStatsConverter.java new file mode 100644 index 0000000000..61424cd06c --- /dev/null +++ b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/JobStatsConverter.java @@ -0,0 +1,24 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE file at the root of the source + * tree and available online at + * + * https://github.com/keeps/roda + */ +package org.roda.core.data.v2.jpa; + +import org.roda.core.data.v2.jobs.JobStats; + +import jakarta.persistence.Converter; + +/** + * JPA converter for JobStats objects. + */ +@Converter +public class JobStatsConverter extends JsonAttributeConverter { + + @Override + protected Class getAttributeClass() { + return JobStats.class; + } +} diff --git a/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/JobUserDetailsListConverter.java b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/JobUserDetailsListConverter.java new file mode 100644 index 0000000000..f5f9cc5693 --- /dev/null +++ b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/JobUserDetailsListConverter.java @@ -0,0 +1,50 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE file at the root of the source + * tree and available online at + * + * https://github.com/keeps/roda + */ +package org.roda.core.data.v2.jpa; + +import java.util.ArrayList; +import java.util.List; + +import org.roda.core.data.exceptions.GenericException; +import org.roda.core.data.utils.JsonUtils; +import org.roda.core.data.v2.jobs.JobUserDetails; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** + * JPA converter for List of JobUserDetails objects. + */ +@Converter +public class JobUserDetailsListConverter implements AttributeConverter, String> { + + private static final Logger LOGGER = LoggerFactory.getLogger(JobUserDetailsListConverter.class); + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null) { + return null; + } + return JsonUtils.getJsonFromObject(attribute); + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isEmpty()) { + return new ArrayList<>(); + } + try { + return JsonUtils.getListFromJson(dbData, JobUserDetails.class); + } catch (GenericException e) { + LOGGER.error("Error converting JSON to List: {}", e.getMessage(), e); + return new ArrayList<>(); + } + } +} diff --git a/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/JsonAttributeConverter.java b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/JsonAttributeConverter.java new file mode 100644 index 0000000000..a1bb3e0537 --- /dev/null +++ b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/JsonAttributeConverter.java @@ -0,0 +1,55 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE file at the root of the source + * tree and available online at + * + * https://github.com/keeps/roda + */ +package org.roda.core.data.v2.jpa; + +import org.roda.core.data.exceptions.GenericException; +import org.roda.core.data.utils.JsonUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.persistence.AttributeConverter; + +/** + * Abstract base class for JPA attribute converters that serialize/deserialize + * objects to/from JSON strings. + * + * @param + * The type of the attribute to convert + */ +public abstract class JsonAttributeConverter implements AttributeConverter { + + private static final Logger LOGGER = LoggerFactory.getLogger(JsonAttributeConverter.class); + + /** + * Returns the Class type for deserialization. + * + * @return the class type of the attribute + */ + protected abstract Class getAttributeClass(); + + @Override + public String convertToDatabaseColumn(T attribute) { + if (attribute == null) { + return null; + } + return JsonUtils.getJsonFromObject(attribute); + } + + @Override + public T convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isEmpty()) { + return null; + } + try { + return JsonUtils.getObjectFromJson(dbData, getAttributeClass()); + } catch (GenericException e) { + LOGGER.error("Error converting JSON to {}: {}", getAttributeClass().getSimpleName(), e.getMessage(), e); + return null; + } + } +} diff --git a/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/ObjectMapConverter.java b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/ObjectMapConverter.java new file mode 100644 index 0000000000..c82cb521ee --- /dev/null +++ b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/ObjectMapConverter.java @@ -0,0 +1,54 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE file at the root of the source + * tree and available online at + * + * https://github.com/keeps/roda + */ +package org.roda.core.data.v2.jpa; + +import java.util.HashMap; +import java.util.Map; + +import org.roda.core.data.exceptions.GenericException; +import org.roda.core.data.utils.JsonUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** + * JPA converter for Map<String, Object> objects. + */ +@Converter +public class ObjectMapConverter implements AttributeConverter, String> { + + private static final Logger LOGGER = LoggerFactory.getLogger(ObjectMapConverter.class); + + @Override + public String convertToDatabaseColumn(Map attribute) { + if (attribute == null) { + return null; + } + return JsonUtils.getJsonFromObject(attribute); + } + + @Override + public Map convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isEmpty()) { + return new HashMap<>(); + } + try { + ObjectMapper mapper = new ObjectMapper(new JsonFactory()); + return mapper.readValue(dbData, new TypeReference>() {}); + } catch (Exception e) { + LOGGER.error("Error converting JSON to Map: {}", e.getMessage(), e); + return new HashMap<>(); + } + } +} diff --git a/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/ReportListConverter.java b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/ReportListConverter.java new file mode 100644 index 0000000000..45096098df --- /dev/null +++ b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/ReportListConverter.java @@ -0,0 +1,50 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE file at the root of the source + * tree and available online at + * + * https://github.com/keeps/roda + */ +package org.roda.core.data.v2.jpa; + +import java.util.ArrayList; +import java.util.List; + +import org.roda.core.data.exceptions.GenericException; +import org.roda.core.data.utils.JsonUtils; +import org.roda.core.data.v2.jobs.Report; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** + * JPA converter for List of Report objects. + */ +@Converter +public class ReportListConverter implements AttributeConverter, String> { + + private static final Logger LOGGER = LoggerFactory.getLogger(ReportListConverter.class); + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null) { + return null; + } + return JsonUtils.getJsonFromObject(attribute); + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isEmpty()) { + return new ArrayList<>(); + } + try { + return JsonUtils.getListFromJson(dbData, Report.class); + } catch (GenericException e) { + LOGGER.error("Error converting JSON to List: {}", e.getMessage(), e); + return new ArrayList<>(); + } + } +} diff --git a/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/SelectedItemsConverter.java b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/SelectedItemsConverter.java new file mode 100644 index 0000000000..ebe96bd571 --- /dev/null +++ b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/SelectedItemsConverter.java @@ -0,0 +1,48 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE file at the root of the source + * tree and available online at + * + * https://github.com/keeps/roda + */ +package org.roda.core.data.v2.jpa; + +import org.roda.core.data.exceptions.GenericException; +import org.roda.core.data.utils.JsonUtils; +import org.roda.core.data.v2.index.select.SelectedItems; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** + * JPA converter for SelectedItems objects. + */ +@Converter +public class SelectedItemsConverter implements AttributeConverter, String> { + + private static final Logger LOGGER = LoggerFactory.getLogger(SelectedItemsConverter.class); + + @Override + public String convertToDatabaseColumn(SelectedItems attribute) { + if (attribute == null) { + return null; + } + return JsonUtils.getJsonFromObject(attribute); + } + + @Override + @SuppressWarnings("unchecked") + public SelectedItems convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isEmpty()) { + return null; + } + try { + return JsonUtils.getObjectFromJson(dbData, SelectedItems.class); + } catch (GenericException e) { + LOGGER.error("Error converting JSON to SelectedItems: {}", e.getMessage(), e); + return null; + } + } +} diff --git a/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/StringListConverter.java b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/StringListConverter.java new file mode 100644 index 0000000000..477ac45874 --- /dev/null +++ b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/StringListConverter.java @@ -0,0 +1,49 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE file at the root of the source + * tree and available online at + * + * https://github.com/keeps/roda + */ +package org.roda.core.data.v2.jpa; + +import java.util.ArrayList; +import java.util.List; + +import org.roda.core.data.exceptions.GenericException; +import org.roda.core.data.utils.JsonUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** + * JPA converter for List<String> objects. + */ +@Converter +public class StringListConverter implements AttributeConverter, String> { + + private static final Logger LOGGER = LoggerFactory.getLogger(StringListConverter.class); + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null) { + return null; + } + return JsonUtils.getJsonFromObject(attribute); + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isEmpty()) { + return new ArrayList<>(); + } + try { + return JsonUtils.getListFromJson(dbData, String.class); + } catch (GenericException e) { + LOGGER.error("Error converting JSON to List: {}", e.getMessage(), e); + return new ArrayList<>(); + } + } +} diff --git a/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/StringMapConverter.java b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/StringMapConverter.java new file mode 100644 index 0000000000..a5d029a4ad --- /dev/null +++ b/roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/jpa/StringMapConverter.java @@ -0,0 +1,43 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE file at the root of the source + * tree and available online at + * + * https://github.com/keeps/roda + */ +package org.roda.core.data.v2.jpa; + +import java.util.HashMap; +import java.util.Map; + +import org.roda.core.data.utils.JsonUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** + * JPA converter for Map<String, String> objects. + */ +@Converter +public class StringMapConverter implements AttributeConverter, String> { + + private static final Logger LOGGER = LoggerFactory.getLogger(StringMapConverter.class); + + @Override + public String convertToDatabaseColumn(Map attribute) { + if (attribute == null) { + return null; + } + return JsonUtils.getJsonFromObject(attribute); + } + + @Override + public Map convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isEmpty()) { + return new HashMap<>(); + } + return JsonUtils.getMapFromJson(dbData); + } +} diff --git a/roda-core/roda-core-tests/pom.xml b/roda-core/roda-core-tests/pom.xml index 011b376fea..8d08bbfa8b 100644 --- a/roda-core/roda-core-tests/pom.xml +++ b/roda-core/roda-core-tests/pom.xml @@ -29,7 +29,7 @@ org.apache.maven.plugins maven-surefire-plugin - -Droda.environment.collect.version=false + -Droda.environment.collect.version=false -Dhttp.nonProxyHosts=localhost|127.0.0.1|172.*|192.*|169.254.169.254|metadata.google.internal|*.svc.cluster.local|*.local|*.googleapis.com|*.google.com true false @@ -74,14 +74,6 @@ mockito-core compile - - org.powermock - powermock-api-mockito2 - - - org.powermock - powermock-module-junit4 - jakarta.ws.rs diff --git a/roda-core/roda-core-tests/src/main/java/org/roda/core/RodaContainersLifecycleListener.java b/roda-core/roda-core-tests/src/main/java/org/roda/core/RodaContainersLifecycleListener.java new file mode 100644 index 0000000000..4e740dcb29 --- /dev/null +++ b/roda-core/roda-core-tests/src/main/java/org/roda/core/RodaContainersLifecycleListener.java @@ -0,0 +1,38 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE file at the root of the source + * tree and available online at + * + * https://github.com/keeps/roda + */ +package org.roda.core; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testng.ISuite; +import org.testng.ISuiteListener; + +/** + * TestNG suite listener that starts all required test infrastructure containers + * (ZooKeeper, Solr, PostgreSQL) before any test or Spring context runs. + *

+ * Registered via {@code testng.xml} so it fires at the very beginning of the + * test suite, ahead of any class loading or Spring Boot context initialization. + * + * @author RODA Community + */ +public class RodaContainersLifecycleListener implements ISuiteListener { + + private static final Logger LOGGER = LoggerFactory.getLogger(RodaContainersLifecycleListener.class); + + @Override + public void onStart(ISuite suite) { + LOGGER.info("RodaContainersLifecycleListener: initializing test containers for suite '{}'", suite.getName()); + TestContainersManager.getInstance(); + } + + @Override + public void onFinish(ISuite suite) { + // Containers are stopped via JVM shutdown hook in TestContainersManager. + } +} diff --git a/roda-core/roda-core-tests/src/main/java/org/roda/core/TestContainersManager.java b/roda-core/roda-core-tests/src/main/java/org/roda/core/TestContainersManager.java new file mode 100644 index 0000000000..8b755a0f03 --- /dev/null +++ b/roda-core/roda-core-tests/src/main/java/org/roda/core/TestContainersManager.java @@ -0,0 +1,188 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE file at the root of the source + * tree and available online at + * + * https://github.com/keeps/roda + */ +package org.roda.core; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.Testcontainers; +import org.testcontainers.containers.BindMode; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; + +/** + * Singleton manager for test infrastructure containers (ZooKeeper, Solr, + * PostgreSQL). + *

+ * Containers are started once per JVM and stopped via a shutdown hook. System + * properties are set so that {@link org.roda.core.config.ConfigurationManager} + * and Spring Boot pick them up before any test or Spring context initialization + * runs. + * + * @author RODA Community + */ +public class TestContainersManager { + + private static final Logger LOGGER = LoggerFactory.getLogger(TestContainersManager.class); + + private static volatile TestContainersManager INSTANCE; + + private final Network network; + private final GenericContainer zookeeper; + private final GenericContainer solr; + private final GenericContainer postgres; + private final GenericContainer mailpit; + private final GenericContainer siegfried; + + @SuppressWarnings("resource") + private TestContainersManager() { + LOGGER.info("Starting test infrastructure containers..."); + + network = Network.newNetwork(); + + // ZooKeeper — exposed so that the RODA CloudSolrClient can connect + zookeeper = new GenericContainer<>(DockerImageName.parse("zookeeper:3.9.1-jre-17")).withNetwork(network) + .withNetworkAliases("zookeeper").withExposedPorts(2181) + .withEnv("ZOO_TICK_TIME", "10000") + .withEnv("ZOO_CFG_EXTRA", "maxSessionTimeout=600000") + .waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(60))); + zookeeper.start(); + LOGGER.info("ZooKeeper started at {}:{}", zookeeper.getHost(), zookeeper.getMappedPort(2181)); + + // Solr — connects to ZooKeeper via the internal Docker network alias. + // Solr registers itself in ZooKeeper using the result of + // InetAddress.getLocalHost().getHostAddress(), which in Docker resolves to + // the container's bridge-network IP. On Linux (CI and most developer + // machines) this IP is directly reachable from the Docker host, so the + // CloudSolrClient can connect without any additional port mapping. + // + // Wait for the log message that Solr emits immediately after registering + // the live node in ZooKeeper. This log fires AFTER ZkController.registerLiveNode(), + // so by the time this wait strategy succeeds, the live node is already + // present in ZooKeeper and RodaCoreFactory.connect() will find it instantly. + // Using a log-based strategy avoids any HTTP proxy interference. + solr = new GenericContainer<>(DockerImageName.parse("solr:9")).withNetwork(network) + .withEnv("ZK_HOST", "zookeeper:2181").withExposedPorts(8983) + .waitingFor(Wait.forLogMessage(".*Register node as live in ZooKeeper.*", 1) + .withStartupTimeout(Duration.ofMinutes(3))); + solr.start(); + LOGGER.info("Solr started at {}:{}", solr.getHost(), solr.getMappedPort(8983)); + + // PostgreSQL + postgres = new GenericContainer<>(DockerImageName.parse("postgres:17")).withEnv("POSTGRES_USER", "admin") + .withEnv("POSTGRES_PASSWORD", "roda").withEnv("POSTGRES_DB", "roda_core_db").withExposedPorts(5432) + .waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(60))); + postgres.start(); + LOGGER.info("PostgreSQL started at {}:{}", postgres.getHost(), postgres.getMappedPort(5432)); + + // Mailpit + mailpit = new GenericContainer<>(DockerImageName.parse("axllent/mailpit:latest")).withExposedPorts(1025, 8025) + .waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(60))); + mailpit.start(); + LOGGER.info("Mailpit started at {}:{}", mailpit.getHost(), mailpit.getMappedPort(1025)); + + // Clamav + GenericContainer clamav = new GenericContainer<>(DockerImageName.parse("clamav/clamav:1.5.2")) + .withExposedPorts(3310).withFileSystemBind("/tmp", "/tmp", BindMode.READ_WRITE) + .waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(60))); + clamav.start(); + + String configContent = """ + TCPSocket %d + TCPAddr %s + """.formatted(clamav.getMappedPort(3310), clamav.getHost()); + + try { + Path tempConfigFile = Paths.get("/tmp/clamd.conf"); + Files.writeString(tempConfigFile, configContent); + } catch (IOException e) { + stopAll(); + throw new RuntimeException("Could not write config file: " + configContent); + } + + LOGGER.info("ClamAV started at {}:{}", clamav.getHost(), clamav.getMappedPort(3310)); + + // Siegfried + siegfried = new GenericContainer<>(DockerImageName.parse("keeps/siegfried:v1.11.0")) + .withEnv("SIEGFRIED_HOST", "0.0.0.0").withEnv("SIEGFRIED_PORT", "5138").withExposedPorts(5138) + .withFileSystemBind("/tmp", "/tmp", BindMode.READ_ONLY) + .waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(60))); + siegfried.start(); + LOGGER.info("Siegfried started at {}:{}", siegfried.getHost(), siegfried.getMappedPort(5138)); + + configureSystemProperties(); + + Runtime.getRuntime().addShutdownHook(new Thread(this::stopAll, "testcontainers-shutdown")); + } + + public static TestContainersManager getInstance() { + if (INSTANCE == null) { + synchronized (TestContainersManager.class) { + if (INSTANCE == null) { + INSTANCE = new TestContainersManager(); + } + } + } + return INSTANCE; + } + + private void configureSystemProperties() { + String zkUrl = zookeeper.getHost() + ":" + zookeeper.getMappedPort(2181); + System.setProperty("RODA_CORE_SOLR_TYPE", "CLOUD"); + System.setProperty("RODA_CORE_SOLR_CLOUD_URLS", zkUrl); + LOGGER.info("Set RODA_CORE_SOLR_CLOUD_URLS={}", zkUrl); + + String pgUrl = "jdbc:postgresql://" + postgres.getHost() + ":" + postgres.getMappedPort(5432) + "/roda_core_db"; + System.setProperty("spring.datasource.url", pgUrl); + System.setProperty("spring.datasource.username", "admin"); + System.setProperty("spring.datasource.password", "roda"); + LOGGER.info("Set spring.datasource.url={}", pgUrl); + + System.setProperty("RODA_CORE_EMAIL_HOST", mailpit.getHost()); + System.setProperty("RODA_CORE_EMAIL_PORT", mailpit.getMappedPort(1025).toString()); + + // Give Solr Cloud more time to establish its ZooKeeper connection in + // environments where ZkClient session establishment is slow. + System.setProperty("RODA_CORE_SOLR_CLOUD_CONNECT_TIMEOUT_MS", "300000"); + // Increase ZK connect timeout so SolrZkClient does not call ZooKeeper.close() + // before the session is established (the close() hangs indefinitely when there + // are no background ZK threads left to process the CLOSESESSION response). + System.setProperty("RODA_CORE_SOLR_CLOUD_ZK_CONNECT_TIMEOUT_MS", "300000"); + System.setProperty("zkConnectTimeout", "300000"); + + System.setProperty("RODA_CORE_PLUGINS_INTERNAL_VIRUS_CHECK_CLAMAV_PARAMS", "-m --stream -c /tmp/clamd.conf"); + + String siegfriedUrl = "http://" + siegfried.getHost() + ":" + siegfried.getMappedPort(5138); + System.setProperty("RODA_CORE_TOOLS_SIEGFRIED_MODE", "server"); + System.setProperty("RODA_CORE_TOOLS_SIEGFRIED_SERVER", siegfriedUrl); + } + + private void stopAll() { + LOGGER.info("Stopping test infrastructure containers..."); + if (solr != null && solr.isRunning()) { + solr.stop(); + } + if (zookeeper != null && zookeeper.isRunning()) { + zookeeper.stop(); + } + if (postgres != null && postgres.isRunning()) { + postgres.stop(); + } + if (network != null) { + network.close(); + } + } +} diff --git a/roda-core/roda-core-tests/src/main/java/org/roda/core/config/TestConfig.java b/roda-core/roda-core-tests/src/main/java/org/roda/core/config/TestConfig.java index 3a6b6dac7d..7901382e9d 100644 --- a/roda-core/roda-core-tests/src/main/java/org/roda/core/config/TestConfig.java +++ b/roda-core/roda-core-tests/src/main/java/org/roda/core/config/TestConfig.java @@ -21,6 +21,6 @@ @EnableAutoConfiguration @ComponentScan(basePackages = "org.roda.core") @EnableJpaRepositories(basePackages = "org.roda.core.repository") -@EntityScan(basePackages = "org.roda.core.entity") +@EntityScan(basePackages = {"org.roda.core.entity", "org.roda.core.data.v2.jobs"}) public class TestConfig { } diff --git a/roda-core/roda-core-tests/src/main/java/org/roda/core/model/JobPersistenceTest.java b/roda-core/roda-core-tests/src/main/java/org/roda/core/model/JobPersistenceTest.java new file mode 100644 index 0000000000..faeee7a5c2 --- /dev/null +++ b/roda-core/roda-core-tests/src/main/java/org/roda/core/model/JobPersistenceTest.java @@ -0,0 +1,314 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE file at the root of the source + * tree and available online at + * + * https://github.com/keeps/roda + */ +package org.roda.core.model; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +import org.roda.core.RodaCoreFactory; +import org.roda.core.TestsHelper; +import org.roda.core.common.iterables.CloseableIterable; +import org.roda.core.config.TestConfig; +import org.roda.core.data.common.RodaConstants; +import org.roda.core.data.exceptions.GenericException; +import org.roda.core.data.exceptions.NotFoundException; +import org.roda.core.data.exceptions.RODAException; +import org.roda.core.data.v2.common.OptionalWithCause; +import org.roda.core.data.v2.index.select.SelectedItemsNone; +import org.roda.core.data.v2.jobs.Job; +import org.roda.core.data.v2.jobs.Job.JOB_STATE; +import org.roda.core.data.v2.jobs.PluginType; +import org.roda.core.data.v2.jobs.Report; +import org.roda.core.repository.job.JobRepository; +import org.roda.core.repository.job.ReportRepository; +import org.roda.core.security.LdapUtilityTestHelper; +import org.roda.core.storage.StorageService; +import org.roda.core.storage.fs.FSUtils; +import org.roda.core.util.IdUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.testng.AbstractTestNGSpringContextTests; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +/** + * Unit tests for the hybrid Job/Report persistence logic. + * Tests that running jobs are stored in the database and flushed to storage on completion. + * + * @author RODA Development Team + */ +@SpringBootTest(classes = TestConfig.class) +@Test(groups = {RodaConstants.TEST_GROUP_ALL, RodaConstants.TEST_GROUP_DEV}) +public class JobPersistenceTest extends AbstractTestNGSpringContextTests { + private static final Logger LOGGER = LoggerFactory.getLogger(JobPersistenceTest.class); + + private static Path basePath; + private static StorageService storage; + private static ModelService model; + private static LdapUtilityTestHelper ldapUtilityTestHelper; + + @Autowired + private JobRepository jobRepository; + + @Autowired + private ReportRepository reportRepository; + + @BeforeClass + public void init() throws IOException, GenericException { + basePath = TestsHelper.createBaseTempDir(getClass(), true); + ldapUtilityTestHelper = new LdapUtilityTestHelper(); + + boolean deploySolr = false; + boolean deployLdap = true; + boolean deployFolderMonitor = false; + boolean deployOrchestrator = false; + boolean deployPluginManager = false; + boolean deployDefaultResources = false; + RodaCoreFactory.instantiateTest(deploySolr, deployLdap, deployFolderMonitor, deployOrchestrator, + deployPluginManager, deployDefaultResources, false, ldapUtilityTestHelper.getLdapUtility()); + + storage = RodaCoreFactory.getStorageService(); + model = RodaCoreFactory.getModelService(); + + LOGGER.debug("Running JobPersistenceTest under storage: {}", basePath); + } + + @AfterClass + public void cleanup() throws NotFoundException, GenericException, IOException { + // Clean up any test data + jobRepository.deleteAll(); + reportRepository.deleteAll(); + + ldapUtilityTestHelper.shutdown(); + RodaCoreFactory.shutdown(); + FSUtils.deletePath(basePath); + } + + /** + * Test that a newly created job with a non-final state (STARTED) is saved to the database + * and NOT written to file storage. + */ + @Test + public void testRunningJobPersistence() throws RODAException { + // Create a running job + String jobId = IdUtils.createUUID(); + Job job = createTestJob(jobId, JOB_STATE.STARTED); + + // Create the job using the model service + model.createJob(job); + + // Verify job exists in database + assertTrue(jobRepository.existsById(jobId), "Job should exist in database"); + + // Verify job retrieved from model service + Job retrievedJob = model.retrieveJob(jobId); + assertNotNull(retrievedJob, "Should be able to retrieve the job"); + assertEquals(retrievedJob.getId(), jobId); + assertEquals(retrievedJob.getState(), JOB_STATE.STARTED); + + // Clean up + model.deleteJob(jobId); + } + + /** + * Test that updating a job to a final state (COMPLETED) flushes it from the database + * to file storage. + */ + @Test + public void testJobFinalization() throws RODAException { + // Create a running job + String jobId = IdUtils.createUUID(); + Job job = createTestJob(jobId, JOB_STATE.STARTED); + + // Create the job using the model service + model.createJob(job); + + // Verify job is in database initially + assertTrue(jobRepository.existsById(jobId), "Job should exist in database initially"); + + // Create a report for this job + Report report = createTestReport(jobId); + model.createOrUpdateJobReport(report, job); + + // Verify report is in database + assertTrue(reportRepository.existsById(report.getId()), "Report should exist in database"); + + // Now update job to final state + job.setState(JOB_STATE.COMPLETED); + job.setEndDate(new Date()); + model.createOrUpdateJob(job); + + // Verify job is no longer in database (flushed to storage) + assertFalse(jobRepository.existsById(jobId), "Job should not exist in database after completion"); + + // Verify report is no longer in database + assertFalse(reportRepository.existsById(report.getId()), "Report should not exist in database after job completion"); + + // Verify job can still be retrieved (from storage) + Job retrievedJob = model.retrieveJob(jobId); + assertNotNull(retrievedJob, "Should be able to retrieve completed job from storage"); + assertEquals(retrievedJob.getState(), JOB_STATE.COMPLETED); + + // Clean up + model.deleteJob(jobId); + } + + /** + * Test that the list method returns both running jobs (from DB) and completed jobs (from storage). + */ + @Test + public void testListingConsistency() throws RODAException { + // Create a running job (will be in DB) + String runningJobId = IdUtils.createUUID(); + Job runningJob = createTestJob(runningJobId, JOB_STATE.STARTED); + model.createJob(runningJob); + + // Create a completed job (will be in storage) + String completedJobId = IdUtils.createUUID(); + Job completedJob = createTestJob(completedJobId, JOB_STATE.COMPLETED); + completedJob.setEndDate(new Date()); + model.createJob(completedJob); + // Force transition to storage by creating and immediately completing + model.createOrUpdateJob(completedJob); + + // List all jobs using model service + try (CloseableIterable> jobsIterable = model.list(Job.class)) { + List allJobs = StreamSupport.stream(jobsIterable.spliterator(), false) + .filter(OptionalWithCause::isPresent) + .map(OptionalWithCause::get) + .collect(Collectors.toList()); + + // Verify both jobs are listed + assertTrue(allJobs.stream().anyMatch(j -> j.getId().equals(runningJobId)), + "Running job should be in the list"); + // Note: completed job may or may not be in list depending on timing + + LOGGER.info("Listed {} jobs total", allJobs.size()); + } catch (IOException e) { + throw new GenericException("Error closing iterable", e); + } + + // Clean up + model.deleteJob(runningJobId); + try { + model.deleteJob(completedJobId); + } catch (NotFoundException e) { + // May have already been deleted or never existed in storage + } + } + + /** + * Test that deleteJob properly cleans up both DB and storage. + */ + @Test + public void testDeletion() throws RODAException { + // Create a running job + String jobId = IdUtils.createUUID(); + Job job = createTestJob(jobId, JOB_STATE.STARTED); + model.createJob(job); + + // Create a report + Report report = createTestReport(jobId); + model.createOrUpdateJobReport(report, job); + + // Verify they exist in DB + assertTrue(jobRepository.existsById(jobId), "Job should exist in database"); + assertTrue(reportRepository.existsById(report.getId()), "Report should exist in database"); + + // Delete the job + model.deleteJob(jobId); + + // Verify both job and reports are deleted from DB + assertFalse(jobRepository.existsById(jobId), "Job should be deleted from database"); + List remainingReports = reportRepository.findByJobId(jobId); + assertTrue(remainingReports.isEmpty(), "Reports should be deleted from database"); + + // Verify job cannot be retrieved + boolean notFound = false; + try { + model.retrieveJob(jobId); + } catch (NotFoundException e) { + notFound = true; + } + assertTrue(notFound, "Job should not be found after deletion"); + } + + /** + * Test report persistence for running jobs. + */ + @Test + public void testReportPersistence() throws RODAException { + // Create a running job + String jobId = IdUtils.createUUID(); + Job job = createTestJob(jobId, JOB_STATE.STARTED); + model.createJob(job); + + // Create multiple reports + Report report1 = createTestReport(jobId); + Report report2 = createTestReport(jobId); + model.createOrUpdateJobReport(report1, job); + model.createOrUpdateJobReport(report2, job); + + // Verify reports are in database + List dbReports = reportRepository.findByJobId(jobId); + assertEquals(dbReports.size(), 2, "Should have 2 reports in database"); + + // Verify reports can be listed through model service + try (CloseableIterable> reportsIterable = model.listJobReports(jobId)) { + List listedReports = StreamSupport.stream(reportsIterable.spliterator(), false) + .filter(OptionalWithCause::isPresent) + .map(OptionalWithCause::get) + .collect(Collectors.toList()); + assertEquals(listedReports.size(), 2, "Should list 2 reports through model service"); + } catch (IOException e) { + throw new GenericException("Error closing iterable", e); + } + + // Clean up + model.deleteJob(jobId); + } + + private Job createTestJob(String jobId, JOB_STATE state) { + Job job = new Job(); + job.setId(jobId); + job.setName("Test Job " + jobId); + job.setUsername(RodaConstants.ADMIN); + job.setState(state); + job.setStartDate(new Date()); + job.setPlugin("org.roda.core.plugins.test.TestPlugin"); + job.setPluginType(PluginType.MISC); + job.setPluginParameters(new HashMap<>()); + job.setSourceObjects(new SelectedItemsNone<>()); + return job; + } + + private Report createTestReport(String jobId) { + Report report = new Report(); + report.setId(IdUtils.createUUID()); + report.setJobId(jobId); + report.setSourceObjectId("test-source-" + UUID.randomUUID().toString().substring(0, 8)); + report.setOutcomeObjectId("test-outcome-" + UUID.randomUUID().toString().substring(0, 8)); + report.setDateCreated(new Date()); + report.setTitle("Test Report"); + return report; + } +} diff --git a/roda-core/roda-core-tests/src/main/java/org/roda/core/plugins/AIPCorruptionRiskAssessmentTest.java b/roda-core/roda-core-tests/src/main/java/org/roda/core/plugins/AIPCorruptionRiskAssessmentTest.java index b49274c209..0dcae1cdc3 100644 --- a/roda-core/roda-core-tests/src/main/java/org/roda/core/plugins/AIPCorruptionRiskAssessmentTest.java +++ b/roda-core/roda-core-tests/src/main/java/org/roda/core/plugins/AIPCorruptionRiskAssessmentTest.java @@ -7,7 +7,9 @@ */ package org.roda.core.plugins; +import java.io.IOException; import java.net.URL; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Collections; @@ -18,15 +20,22 @@ import org.roda.core.RodaCoreFactory; import org.roda.core.TestsHelper; import org.roda.core.data.common.RodaConstants; +import org.roda.core.data.exceptions.AlreadyExistsException; +import org.roda.core.data.exceptions.AuthorizationDeniedException; +import org.roda.core.data.exceptions.GenericException; +import org.roda.core.data.exceptions.NotFoundException; import org.roda.core.data.exceptions.RODAException; +import org.roda.core.data.exceptions.RequestNotValidException; import org.roda.core.data.v2.index.filter.Filter; import org.roda.core.data.v2.index.select.SelectedItemsList; import org.roda.core.data.v2.ip.AIP; +import org.roda.core.data.v2.ip.File; import org.roda.core.data.v2.jobs.Job; import org.roda.core.data.v2.jobs.PluginState; import org.roda.core.data.v2.jobs.PluginType; import org.roda.core.data.v2.jobs.Report; import org.roda.core.data.v2.risks.RiskIncidence; +import org.roda.core.data.v2.validation.ValidationException; import org.roda.core.index.IndexService; import org.roda.core.index.IndexTestUtils; import org.roda.core.model.ModelService; @@ -48,12 +57,12 @@ public class AIPCorruptionRiskAssessmentTest { private static final Logger LOGGER = LoggerFactory.getLogger(AIPCorruptionRiskAssessmentTest.class); - private static Path basePath; + private Path basePath; - private static ModelService model; - private static IndexService index; - private static LdapUtilityTestHelper ldapUtilityTestHelper; - private static StorageService corporaService; + private ModelService model; + private IndexService index; + private LdapUtilityTestHelper ldapUtilityTestHelper; + private StorageService corporaService; @BeforeMethod public void setUp() throws Exception { @@ -97,13 +106,44 @@ public void testAIPCorruption() throws RODAException { SelectedItemsList.create(AIP.class, Collections.singletonList(aipId))); List jobReports = TestsHelper.getJobReports(index, job, false); - int count = StringUtils.countMatches(jobReports.get(0).getPluginDetails(), "

"); + index.commit(RiskIncidence.class); long incidences = index.count(RiskIncidence.class, Filter.ALL); - // 3 errors: 1 checksum checking error, 1 file without premis, 1 premis - // without file Assert.assertEquals(count, 3); - Assert.assertEquals(incidences, 2); - Assert.assertEquals(jobReports.get(0).getPluginState(), PluginState.FAILURE); + Assert.assertEquals(incidences, 3, "3 incidences should be reported for the corrupted AIP: 1 checksum error, 1 file without PREMIS, 1 PREMIS without file"); + Assert.assertEquals(jobReports.getFirst().getPluginState(), PluginState.FAILURE); + } + + @Test + public void testFileRemovedFromStorage() throws RequestNotValidException, AuthorizationDeniedException, + ValidationException, AlreadyExistsException, NotFoundException, GenericException, IOException { + String aipId = IdUtils.createUUID(); + AIP aip = model.createAIP(aipId, corporaService, + DefaultStoragePath.parse(CorporaConstants.SOURCE_AIP_CONTAINER, "AIP_4"), RodaConstants.ADMIN); + + File file = model.retrieveFile(aip.getId(), aip.getRepresentations().getFirst().getId(), List.of(), + "2012-roda-promo-en.pdf"); + Path path = model.getDirectAccess(file).getPath(); + + Assert.assertTrue(path.toFile().exists()); + + Files.delete(path); + Assert.assertFalse(path.toFile().exists()); + + Job job = TestsHelper.executeJob(AIPCorruptionRiskAssessmentPlugin.class, PluginType.AIP_TO_AIP, + SelectedItemsList.create(AIP.class, Collections.singletonList(aipId))); + + List jobReports = TestsHelper.getJobReports(index, job, false); + + Assert.assertEquals(job.getJobStats().getCompletionPercentage(), 100, + "Job should be completed even if file is missing"); + Assert.assertEquals(job.getJobStats().getSourceObjectsProcessedWithFailure(), 1, + "Job should report 1 source object processed with failure due to missing file"); + Assert.assertEquals(jobReports.getFirst().getPluginState(), PluginState.FAILURE, + "Plugin should report failure due to missing file"); + + index.commit(RiskIncidence.class); + long incidences = index.count(RiskIncidence.class, Filter.ALL); + Assert.assertEquals(incidences, 1, "There should be 1 risk incidence reported due to missing file"); } } diff --git a/roda-core/roda-core-tests/src/main/java/org/roda/core/storage/DeleteAIPPermissionTest.java b/roda-core/roda-core-tests/src/main/java/org/roda/core/storage/DeleteAIPPermissionTest.java index a0bc0b270c..7a7a11c969 100644 --- a/roda-core/roda-core-tests/src/main/java/org/roda/core/storage/DeleteAIPPermissionTest.java +++ b/roda-core/roda-core-tests/src/main/java/org/roda/core/storage/DeleteAIPPermissionTest.java @@ -65,8 +65,8 @@ public class DeleteAIPPermissionTest { private static LdapUtilityTestHelper ldapUtilityTestHelper; @BeforeMethod - public static void setUp() throws Exception { - basePath = TestsHelper.createBaseTempDir(FileStorageServiceTest.class, true); + public void setUp() throws Exception { + basePath = TestsHelper.createBaseTempDir(this.getClass(), true); ldapUtilityTestHelper = new LdapUtilityTestHelper(); RodaCoreFactory.instantiateTest(true, true, true, true, true, false, false, ldapUtilityTestHelper.getLdapUtility()); diff --git a/roda-core/roda-core-tests/src/main/java/org/roda/core/storage/TransactionalStorageServiceTest.java b/roda-core/roda-core-tests/src/main/java/org/roda/core/storage/TransactionalStorageServiceTest.java index a9852a2977..64acabf2ff 100644 --- a/roda-core/roda-core-tests/src/main/java/org/roda/core/storage/TransactionalStorageServiceTest.java +++ b/roda-core/roda-core-tests/src/main/java/org/roda/core/storage/TransactionalStorageServiceTest.java @@ -30,6 +30,7 @@ import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.IOUtils; import org.hamcrest.Matchers; +import org.mockito.Mockito; import org.roda.core.RodaCoreFactory; import org.roda.core.TestsHelper; import org.roda.core.common.iterables.CloseableIterable; @@ -1948,4 +1949,47 @@ public void testVersioningWithMultipleUpdates() throws RODATransactionException, Binary rolledBackBinary = mainStorage.getBinary(binaryStoragePath); testBinaryContent(rolledBackBinary, payload1); } + + @Test + public void testRollbackAfterCommit() throws RODATransactionException, RequestNotValidException, + AuthorizationDeniedException, AlreadyExistsException, GenericException, NotFoundException { + + StorageService mockMainStorageService = Mockito.spy(mainStorage); + + // 1.1) start transaction + TransactionalContext context1 = transactionManager.beginTestTransaction(mockMainStorageService); + StorageService storage1 = context1.transactionalStorageService(); + // 1.2) create container + final StoragePath containerStoragePath = StorageTestUtils.generateRandomContainerStoragePath(); + storage1.createContainer(containerStoragePath); + // 1.3) create a random file + final StoragePath binaryStoragePath = StorageTestUtils.generateRandomResourceStoragePathUnder(containerStoragePath); + final ContentPayload payload1 = new RandomMockContentPayload(); + storage1.createBinary(binaryStoragePath, payload1, false); + + // 1.4) create a second random file + final StoragePath binaryStoragePath2 = StorageTestUtils + .generateRandomResourceStoragePathUnder(containerStoragePath); + final ContentPayload payload2 = new RandomMockContentPayload(); + storage1.createBinary(binaryStoragePath2, payload2, false); + + Mockito.doThrow(new GenericException("Mock exception for testing rollback after commit")) + .when(mockMainStorageService).createBinary(Mockito.eq(binaryStoragePath2), Mockito.any(), Mockito.anyBoolean()); + + // 1.5) end transaction + try { + transactionManager.endTransaction(context1.transactionLog().getId()); + Assert.fail("Expected exception was not thrown"); + } catch (RODATransactionException e) { + LOGGER.info("Caught expected exception: {}", e.getMessage()); + Assert.assertTrue(mainStorage.exists(binaryStoragePath), "The first binary should exist after commit"); + Assert.assertTrue(mainStorage.exists(containerStoragePath), "The container should exist after commit"); + transactionManager.rollbackTransaction(context1.transactionLog().getId()); + } + + Assert.assertFalse(mainStorage.exists(binaryStoragePath), "The first binary should not exist after rollback"); + Assert.assertFalse(mainStorage.exists(binaryStoragePath2), "The second binary should not exist after rollback"); + Assert.assertFalse(mainStorage.exists(containerStoragePath), "The container should not exist after rollback"); + + } } diff --git a/roda-core/roda-core-tests/src/main/resources/clamd.conf b/roda-core/roda-core-tests/src/main/resources/clamd.conf new file mode 100644 index 0000000000..5727b141c4 --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/clamd.conf @@ -0,0 +1,2 @@ +TCPSocket 3310 +TCPAddr localhost diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/aip.json b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/aip.json new file mode 100644 index 0000000000..be68eb9278 --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/aip.json @@ -0,0 +1,74 @@ +{ + "id": "6f8cbcb2-8d5b-4dfa-a38b-6a0779d579c2", + "type": "OTHER", + "state": "ACTIVE", + "permissions": { + "users": { + "CREATE": [ + "admin" + ], + "READ": [ + "admin" + ], + "UPDATE": [ + "admin" + ], + "DELETE": [ + "admin" + ], + "GRANT": [ + "admin" + ] + }, + "groups": { + "CREATE": [], + "READ": [], + "UPDATE": [], + "DELETE": [], + "GRANT": [] + } + }, + "descriptiveMetadata": [ + { + "id": "ead2002.xml", + "type": "EAD", + "version": "2002" + } + ], + "representations": [ + { + "aipId": "6f8cbcb2-8d5b-4dfa-a38b-6a0779d579c2", + "id": "rep1", + "original": true, + "representationStates": [], + "type": "Other", + "hasShallowFiles": false, + "createdOn": 1770398258477, + "createdBy": "admin", + "updatedOn": 1770398264029, + "updatedBy": "admin", + "descriptiveMetadata": [], + "technicalMetadata": [] + } + ], + "ingestSIPUUID": "7084c01f-c61e-38f4-802b-37c2f94f90ee", + "ingestSIPIds": [ + "uuid-a9916a17-9826-43fa-8a9b-47b85b55d04c" + ], + "ingestJobId": "5c2cea51-1a3c-41f4-8942-009ac8dfb28c", + "ingestUpdateJobIds": [], + "hasShallowFiles": false, + "format": { + "name": null, + "version": null + }, + "relationships": [], + "createdOn": 1770398258094, + "createdBy": "admin", + "updatedOn": 1770398265212, + "updatedBy": "admin", + "disposal": { + "holds": [], + "transitiveHolds": [] + } +} \ No newline at end of file diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/descriptive/ead2002.xml b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/descriptive/ead2002.xml new file mode 100644 index 0000000000..0f870d3c0c --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/descriptive/ead2002.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + Generated by RODA version 2.0 + + + + + + Sample + uuid-a9916a17-9826-43fa-8a9b-47b85b55d04c + + + English + + + + +

+ 2023-04-05 +

+
+ + +
+
diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:0c55033a-f4a9-4b13-9115-3ca0f44db195.xml b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:0c55033a-f4a9-4b13-9115-3ca0f44db195.xml new file mode 100644 index 0000000000..427de53d17 --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:0c55033a-f4a9-4b13-9115-3ca0f44db195.xml @@ -0,0 +1,40 @@ + + + + URN + urn:roda:premis:event:0c55033a-f4a9-4b13-9115-3ca0f44db195 + + virus check + 2026-02-06T17:17:41.849Z + + Scanned package for malicious programs using ClamAV. + + + SUCCESS + + The package does not contain any known malicious programs. +/roda/data/staging-storage/81f8b114-82aa-48a7-90e1-802876261381/aip/6f8cbcb2-8d5b-4dfa-a38b-6a0779d579c2: OK + +----------- SCAN SUMMARY ----------- +Infected files: 0 +Time: 2.429 sec (0 m 2 s) +Start Date: 2026:02:06 17:17:39 +End Date: 2026:02:06 17:17:41 + + + + + URN + urn:roda:premis:agent:org.roda.core.plugins.base.antivirus.AntivirusPlugin@ClamAV 1.5.1/27904/Fri Feb 6 07:25:08 2026 + + + URN + urn:roda:premis:agent:admin + implementer + + + URN + urn:roda:AIP:6f8cbcb2-8d5b-4dfa-a38b-6a0779d579c2 + outcome + + diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:10c334ef-a287-4995-9dd2-5070394f32b9.xml b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:10c334ef-a287-4995-9dd2-5070394f32b9.xml new file mode 100644 index 0000000000..8e596f9047 --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:10c334ef-a287-4995-9dd2-5070394f32b9.xml @@ -0,0 +1,37 @@ + + + + URN + urn:roda:premis:event:10c334ef-a287-4995-9dd2-5070394f32b9 + + unpacking + 2026-02-06T17:17:38.911Z + + Extracted objects from package in E-ARK SIP 2 format. + + + SUCCESS + + The SIP has been successfully unpacked. + + + + URN + urn:roda:premis:agent:org.roda.core.plugins.base.ingest.EARKSIP2ToAIPPlugin@1.0 + + + URN + urn:roda:premis:agent:admin + implementer + + + URN + urn:roda:TRANSFERRED_RESOURCE:eark_sip_2.0.4.zip + source + + + URN + urn:roda:AIP:6f8cbcb2-8d5b-4dfa-a38b-6a0779d579c2 + outcome + + diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:253eec92-0960-4ee1-b0b2-156a6e3ff3c1.xml b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:253eec92-0960-4ee1-b0b2-156a6e3ff3c1.xml new file mode 100644 index 0000000000..06e8bbcc00 --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:253eec92-0960-4ee1-b0b2-156a6e3ff3c1.xml @@ -0,0 +1,37 @@ + + + + URN + urn:roda:premis:event:253eec92-0960-4ee1-b0b2-156a6e3ff3c1 + + ingest start + 2026-02-06T17:17:37.628Z + + The ingest process has started. + + + SUCCESS + + The ingest process has successfully ended. + + + + URN + urn:roda:premis:agent:org.roda.core.plugins.base.ingest.v2.ConfigurableIngestPlugin@2.0 + + + URN + urn:roda:premis:agent:admin + implementer + + + URN + urn:roda:TRANSFERRED_RESOURCE:eark_sip_2.0.4.zip + source + + + URN + urn:roda:AIP:6f8cbcb2-8d5b-4dfa-a38b-6a0779d579c2 + outcome + + diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:2decab05-8246-4753-9fe2-3617454757bd.xml b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:2decab05-8246-4753-9fe2-3617454757bd.xml new file mode 100644 index 0000000000..c979690fa6 --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:2decab05-8246-4753-9fe2-3617454757bd.xml @@ -0,0 +1,47 @@ + + + + URN + urn:roda:premis:event:2decab05-8246-4753-9fe2-3617454757bd + + format identification + 2026-02-06T17:17:44.496Z + + Identified the object's file formats and versions using Siegfried. + + + SUCCESS + + File formats were identified and recorded in PREMIS objects. + + + + URN + urn:roda:premis:agent:org.roda.core.plugins.base.characterization.SiegfriedPlugin@1.11.0 + + + URN + urn:roda:premis:agent:admin + implementer + + + URN + urn:roda:FILE:4e69b3bb-406d-3c13-bc4e-386791f27b9a + source + + + URN + urn:roda:FILE:e8c301d4-9847-389f-aa28-77a3edb87289 + source + + + URN + urn:roda:FILE:7ddd5ebc-6cea-3905-a76f-702dd8cacdb2 + source + + + URN + urn:roda:FILE:dd39e79f-5f4b-3e95-85a7-f68f5e864ab1 + source + + diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:36c1f527-febe-44d1-83d3-89f5ee0deffb.xml b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:36c1f527-febe-44d1-83d3-89f5ee0deffb.xml new file mode 100644 index 0000000000..a7578300c8 --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:36c1f527-febe-44d1-83d3-89f5ee0deffb.xml @@ -0,0 +1,37 @@ + + + + URN + urn:roda:premis:event:36c1f527-febe-44d1-83d3-89f5ee0deffb + + wellformedness check + 2026-02-06T17:17:39.018Z + + Checked that the received SIP is well formed, complete and that no unexpected files were included. + + + SUCCESS + + The SIP was well formed and complete. + + + + URN + urn:roda:premis:agent:org.roda.core.plugins.base.ingest.EARKSIP2ToAIPPlugin@1.0 + + + URN + urn:roda:premis:agent:admin + implementer + + + URN + urn:roda:TRANSFERRED_RESOURCE:eark_sip_2.0.4.zip + source + + + URN + urn:roda:AIP:6f8cbcb2-8d5b-4dfa-a38b-6a0779d579c2 + outcome + + diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:43a876a5-8249-4dff-82d2-c91a07b0b4f9.xml b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:43a876a5-8249-4dff-82d2-c91a07b0b4f9.xml new file mode 100644 index 0000000000..8193573871 --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:43a876a5-8249-4dff-82d2-c91a07b0b4f9.xml @@ -0,0 +1,32 @@ + + + + URN + urn:roda:premis:event:43a876a5-8249-4dff-82d2-c91a07b0b4f9 + + accession + 2026-02-06T17:17:45.047Z + + Added package to the inventory. After this point, the responsibility for the digital content’s preservation is passed on to the repository. + + + SUCCESS + + The AIP was successfully added to the repository's inventory. + + + + URN + urn:roda:premis:agent:org.roda.core.plugins.base.ingest.AutoAcceptSIPPlugin@1.0 + + + URN + urn:roda:premis:agent:admin + implementer + + + URN + urn:roda:AIP:6f8cbcb2-8d5b-4dfa-a38b-6a0779d579c2 + outcome + + diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:6a9c379b-3041-4590-a73f-72c48fca8f93.xml b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:6a9c379b-3041-4590-a73f-72c48fca8f93.xml new file mode 100644 index 0000000000..f35048cba2 --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:6a9c379b-3041-4590-a73f-72c48fca8f93.xml @@ -0,0 +1,32 @@ + + + + URN + urn:roda:premis:event:6a9c379b-3041-4590-a73f-72c48fca8f93 + + message digest calculation + 2026-02-06T17:17:43.176Z + + Created base PREMIS objects with file original name and file fixity information (SHA-256). + + + SUCCESS + + PREMIS objects were successfully created. + + + + URN + urn:roda:premis:agent:org.roda.core.plugins.base.characterization.PremisSkeletonPlugin@1.0 + + + URN + urn:roda:premis:agent:admin + implementer + + + URN + urn:roda:AIP:6f8cbcb2-8d5b-4dfa-a38b-6a0779d579c2 + outcome + + diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:7eba0f70-94f7-4de2-b483-9e69c2866efd.xml b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:7eba0f70-94f7-4de2-b483-9e69c2866efd.xml new file mode 100644 index 0000000000..64c1b4dc22 --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:7eba0f70-94f7-4de2-b483-9e69c2866efd.xml @@ -0,0 +1,33 @@ + + + + URN + urn:roda:premis:event:7eba0f70-94f7-4de2-b483-9e69c2866efd + + authorization check + 2026-02-06T17:17:44.696Z + + User permissions have been checked to ensure that he has sufficient authorization to store the AIP under the desired node of the classification scheme. + + + SUCCESS + + The user has enough permissions to deposit the AIP under the designated node of the classification scheme +Done with checking user authorization for AIP 6f8cbcb2-8d5b-4dfa-a38b-6a0779d579c2 + + + + URN + urn:roda:premis:agent:org.roda.core.plugins.base.ingest.VerifyUserAuthorizationPlugin@1.0 + + + URN + urn:roda:premis:agent:admin + implementer + + + URN + urn:roda:AIP:6f8cbcb2-8d5b-4dfa-a38b-6a0779d579c2 + outcome + + diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:bb46d104-fd4d-4cc3-8bba-a449dde0c908.xml b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:bb46d104-fd4d-4cc3-8bba-a449dde0c908.xml new file mode 100644 index 0000000000..1c90c28aee --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:bb46d104-fd4d-4cc3-8bba-a449dde0c908.xml @@ -0,0 +1,37 @@ + + + + URN + urn:roda:premis:event:bb46d104-fd4d-4cc3-8bba-a449dde0c908 + + ingest end + 2026-02-06T17:17:45.162Z + + The ingest process has ended. + + + SUCCESS + + The ingest process has successfully ended. + + + + URN + urn:roda:premis:agent:org.roda.core.plugins.base.ingest.v2.ConfigurableIngestPlugin@2.0 + + + URN + urn:roda:premis:agent:admin + implementer + + + URN + urn:roda:TRANSFERRED_RESOURCE:eark_sip_2.0.4.zip + source + + + URN + urn:roda:AIP:6f8cbcb2-8d5b-4dfa-a38b-6a0779d579c2 + outcome + + diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:cd37b89a-f9c6-4413-a0e1-6d9aafe6838d.xml b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:cd37b89a-f9c6-4413-a0e1-6d9aafe6838d.xml new file mode 100644 index 0000000000..e4c25bb959 --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/metadata/preservation/urn:roda:premis:event:cd37b89a-f9c6-4413-a0e1-6d9aafe6838d.xml @@ -0,0 +1,32 @@ + + + + URN + urn:roda:premis:event:cd37b89a-f9c6-4413-a0e1-6d9aafe6838d + + wellformedness check + 2026-02-06T17:17:42.095Z + + Checked whether the descriptive metadata is included in the SIP and if this metadata is valid according to the established policy. + + + SUCCESS + + Descriptive metadata is well formed and complete. + + + + URN + urn:roda:premis:agent:org.roda.core.plugins.base.preservation.DescriptiveMetadataValidationPlugin@1.0 + + + URN + urn:roda:premis:agent:admin + implementer + + + URN + urn:roda:AIP:6f8cbcb2-8d5b-4dfa-a38b-6a0779d579c2 + outcome + + diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/data/2012-roda-promo-en.pdf b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/data/2012-roda-promo-en.pdf new file mode 100644 index 0000000000..93adb386bd Binary files /dev/null and b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/data/2012-roda-promo-en.pdf differ diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/data/folder/RODA 2 logo-circle-black.svg b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/data/folder/RODA 2 logo-circle-black.svg new file mode 100644 index 0000000000..a2ca9c1f6c --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/data/folder/RODA 2 logo-circle-black.svg @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/data/folder/RODA 2 logo-circle-white.svg b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/data/folder/RODA 2 logo-circle-white.svg new file mode 100644 index 0000000000..d9572123e8 --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/data/folder/RODA 2 logo-circle-white.svg @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/data/folder/subfolder/RODA 2 logo.svg b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/data/folder/subfolder/RODA 2 logo.svg new file mode 100644 index 0000000000..0a7105f38c --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/data/folder/subfolder/RODA 2 logo.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/other/Siegfried/2012-roda-promo-en.pdf.json b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/other/Siegfried/2012-roda-promo-en.pdf.json new file mode 100644 index 0000000000..269ec6c335 --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/other/Siegfried/2012-roda-promo-en.pdf.json @@ -0,0 +1 @@ +{"filename":"/roda/data/staging-storage/81f8b114-82aa-48a7-90e1-802876261381/aip/6f8cbcb2-8d5b-4dfa-a38b-6a0779d579c2/representations/rep1/data/2012-roda-promo-en.pdf","filesize":5642143,"modified":"2026-02-06T17:17:38Z","errors":"","matches":[{"ns":"pronom","id":"fmt/17","format":"Acrobat PDF 1.3 - Portable Document Format","version":"1.3","mime":"application/pdf","class":"Page Description","basis":"extension match pdf; byte match at [[0 8] [5642137 5]]","warning":""}]} \ No newline at end of file diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/other/Siegfried/folder/RODA 2 logo-circle-black.svg.json b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/other/Siegfried/folder/RODA 2 logo-circle-black.svg.json new file mode 100644 index 0000000000..c421102b97 --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/other/Siegfried/folder/RODA 2 logo-circle-black.svg.json @@ -0,0 +1 @@ +{"filename":"/roda/data/staging-storage/81f8b114-82aa-48a7-90e1-802876261381/aip/6f8cbcb2-8d5b-4dfa-a38b-6a0779d579c2/representations/rep1/data/folder/RODA 2 logo-circle-black.svg","filesize":1417,"modified":"2026-02-06T17:17:38Z","errors":"","matches":[{"ns":"pronom","id":"fmt/92","format":"Scalable Vector Graphics","version":"1.1","mime":"image/svg+xml","class":"Image (Vector)","basis":"extension match svg; byte match at [[0 19] [236 4] [241 13] [1411 4]]","warning":""}]} \ No newline at end of file diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/other/Siegfried/folder/RODA 2 logo-circle-white.svg.json b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/other/Siegfried/folder/RODA 2 logo-circle-white.svg.json new file mode 100644 index 0000000000..a12ac8cf0f --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/other/Siegfried/folder/RODA 2 logo-circle-white.svg.json @@ -0,0 +1 @@ +{"filename":"/roda/data/staging-storage/81f8b114-82aa-48a7-90e1-802876261381/aip/6f8cbcb2-8d5b-4dfa-a38b-6a0779d579c2/representations/rep1/data/folder/RODA 2 logo-circle-white.svg","filesize":1428,"modified":"2026-02-06T17:17:38Z","errors":"","matches":[{"ns":"pronom","id":"fmt/92","format":"Scalable Vector Graphics","version":"1.1","mime":"image/svg+xml","class":"Image (Vector)","basis":"extension match svg; byte match at [[0 19] [236 4] [241 13] [1422 4]]","warning":""}]} \ No newline at end of file diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/other/Siegfried/folder/subfolder/RODA 2 logo.svg.json b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/other/Siegfried/folder/subfolder/RODA 2 logo.svg.json new file mode 100644 index 0000000000..b112f86630 --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/other/Siegfried/folder/subfolder/RODA 2 logo.svg.json @@ -0,0 +1 @@ +{"filename":"/roda/data/staging-storage/81f8b114-82aa-48a7-90e1-802876261381/aip/6f8cbcb2-8d5b-4dfa-a38b-6a0779d579c2/representations/rep1/data/folder/subfolder/RODA 2 logo.svg","filesize":4132,"modified":"2026-02-06T17:17:38Z","errors":"","matches":[{"ns":"pronom","id":"fmt/92","format":"Scalable Vector Graphics","version":"1.1","mime":"image/svg+xml","class":"Image (Vector)","basis":"extension match svg; byte match at [[0 19] [236 4] [241 13] [4126 4]]","warning":""}]} \ No newline at end of file diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/preservation/folder/subfolder/urn:roda:premis:file:RODA 2 logo.svg.xml b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/preservation/folder/subfolder/urn:roda:premis:file:RODA 2 logo.svg.xml new file mode 100644 index 0000000000..32289ec21b --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/preservation/folder/subfolder/urn:roda:premis:file:RODA 2 logo.svg.xml @@ -0,0 +1,81 @@ + + + + URN-local + urn:roda:premis:file:RODA 2 logo.svg + + + URN + urn:roda:premis:file:dd39e79f-5f4b-3e95-85a7-f68f5e864ab1 + + + full + + + + SHA-1 + 8A3C25E19299CACBE813B0C5015DD4F1922056EE + RODA + + + SHA-256 + 64046138FFDC80FD03F8616E856A095F41F895DB911A59D03C7EB63A30E40C1D + RODA + + + MD5 + F5785E4D2F335AD0ECE35F783E7F7630 + RODA + + 4132 + + + Scalable Vector Graphics + 1.1 + + + + + pronom + fmt/92 + + + + + mime + image/svg+xml + + + + + + SHA-1 + 8A3C25E19299CACBE813B0C5015DD4F1922056EE + RODA + + + SHA-256 + 64046138FFDC80FD03F8616E856A095F41F895DB911A59D03C7EB63A30E40C1D + RODA + + + MD5 + F5785E4D2F335AD0ECE35F783E7F7630 + RODA + + 4132 + + + + + + + + RODA 2 logo.svg + + + URI + file:///roda/data/staging-storage/81f8b114-82aa-48a7-90e1-802876261381/aip/6f8cbcb2-8d5b-4dfa-a38b-6a0779d579c2/representations/rep1/data/folder/subfolder/RODA%202%20logo.svg + + + diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/preservation/folder/urn:roda:premis:file:RODA 2 logo-circle-black.svg.xml b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/preservation/folder/urn:roda:premis:file:RODA 2 logo-circle-black.svg.xml new file mode 100644 index 0000000000..134843cd4f --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/preservation/folder/urn:roda:premis:file:RODA 2 logo-circle-black.svg.xml @@ -0,0 +1,81 @@ + + + + URN-local + urn:roda:premis:file:RODA 2 logo-circle-black.svg + + + URN + urn:roda:premis:file:e8c301d4-9847-389f-aa28-77a3edb87289 + + + full + + + + SHA-1 + 481F03E7C76506F03364B0226E210B3D90B028AC + RODA + + + SHA-256 + 91564181042E1D25CF5525C72CA5CF6B5272E0C5FB6D7E8F8AF6851D98C9AE62 + RODA + + + MD5 + 75546AD74FFF9DCAAAB87766C8EA3677 + RODA + + 1417 + + + Scalable Vector Graphics + 1.1 + + + + + pronom + fmt/92 + + + + + mime + image/svg+xml + + + + + + SHA-1 + 481F03E7C76506F03364B0226E210B3D90B028AC + RODA + + + SHA-256 + 91564181042E1D25CF5525C72CA5CF6B5272E0C5FB6D7E8F8AF6851D98C9AE62 + RODA + + + MD5 + 75546AD74FFF9DCAAAB87766C8EA3677 + RODA + + 1417 + + + + + + + + RODA 2 logo-circle-black.svg + + + URI + file:///roda/data/staging-storage/81f8b114-82aa-48a7-90e1-802876261381/aip/6f8cbcb2-8d5b-4dfa-a38b-6a0779d579c2/representations/rep1/data/folder/RODA%202%20logo-circle-black.svg + + + diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/preservation/folder/urn:roda:premis:file:RODA 2 logo-circle-white.svg.xml b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/preservation/folder/urn:roda:premis:file:RODA 2 logo-circle-white.svg.xml new file mode 100644 index 0000000000..41bdf8b40d --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/preservation/folder/urn:roda:premis:file:RODA 2 logo-circle-white.svg.xml @@ -0,0 +1,81 @@ + + + + URN-local + urn:roda:premis:file:RODA 2 logo-circle-white.svg + + + URN + urn:roda:premis:file:7ddd5ebc-6cea-3905-a76f-702dd8cacdb2 + + + full + + + + SHA-1 + 90AD19B213BFAD67605B7077994128D50034CAB1 + RODA + + + SHA-256 + 3D419948CDC7EB82CAB42FEA19898452454935B1448FB14FC41DF8F8FBE461CB + RODA + + + MD5 + FDFF292984B0F3D85FE51E8A3D39D34F + RODA + + 1428 + + + Scalable Vector Graphics + 1.1 + + + + + pronom + fmt/92 + + + + + mime + image/svg+xml + + + + + + SHA-1 + 90AD19B213BFAD67605B7077994128D50034CAB1 + RODA + + + SHA-256 + 3D419948CDC7EB82CAB42FEA19898452454935B1448FB14FC41DF8F8FBE461CB + RODA + + + MD5 + FDFF292984B0F3D85FE51E8A3D39D34F + RODA + + 1428 + + + + + + + + RODA 2 logo-circle-white.svg + + + URI + file:///roda/data/staging-storage/81f8b114-82aa-48a7-90e1-802876261381/aip/6f8cbcb2-8d5b-4dfa-a38b-6a0779d579c2/representations/rep1/data/folder/RODA%202%20logo-circle-white.svg + + + diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/preservation/urn:roda:premis:file:2012-roda-promo-en.pdf.xml b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/preservation/urn:roda:premis:file:2012-roda-promo-en.pdf.xml new file mode 100644 index 0000000000..015b0bea76 --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/preservation/urn:roda:premis:file:2012-roda-promo-en.pdf.xml @@ -0,0 +1,81 @@ + + + + URN-local + urn:roda:premis:file:2012-roda-promo-en.pdf + + + URN + urn:roda:premis:file:4e69b3bb-406d-3c13-bc4e-386791f27b9a + + + full + + + + SHA-1 + 2E9D4BD1AFF5F0D4EE1B4684597B8FD07E92D23E + RODA + + + SHA-256 + 398FF7B8487BAE1F1A7ACD774E1CA41011D0D0EE93FD79FCF1D2433D4966FB3B + RODA + + + MD5 + C45F903135AC746A2B75070634E920E1 + RODA + + 5642143 + + + Acrobat PDF 1.3 - Portable Document Format + 1.3 + + + + + pronom + fmt/17 + + + + + mime + application/pdf + + + + + + SHA-1 + 2E9D4BD1AFF5F0D4EE1B4684597B8FD07E92D23E + RODA + + + SHA-256 + 398FF7B8487BAE1F1A7ACD774E1CA41011D0D0EE93FD79FCF1D2433D4966FB3B + RODA + + + MD5 + C45F903135AC746A2B75070634E920E1 + RODA + + 5642143 + + + + + + + + 2012-roda-promo-en.pdf + + + URI + file:///roda/data/staging-storage/81f8b114-82aa-48a7-90e1-802876261381/aip/6f8cbcb2-8d5b-4dfa-a38b-6a0779d579c2/representations/rep1/data/2012-roda-promo-en.pdf + + + diff --git a/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/preservation/urn:roda:premis:representation:c3f8f330-1459-3c7f-9c2c-472db5c383c6.xml b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/preservation/urn:roda:premis:representation:c3f8f330-1459-3c7f-9c2c-472db5c383c6.xml new file mode 100644 index 0000000000..d52e25690f --- /dev/null +++ b/roda-core/roda-core-tests/src/main/resources/corpora/aip/AIP_4/representations/rep1/metadata/preservation/urn:roda:premis:representation:c3f8f330-1459-3c7f-9c2c-472db5c383c6.xml @@ -0,0 +1,42 @@ + + + + URN + urn:roda:premis:representation:c3f8f330-1459-3c7f-9c2c-472db5c383c6 + + + + + + structural + hasPart + + URN + urn:roda:premis:file:folder-RODA 2 logo-circle-white.svg + + + + structural + hasPart + + URN + urn:roda:premis:file:folder-subfolder-RODA 2 logo.svg + + + + structural + hasPart + + URN + urn:roda:premis:file:folder-RODA 2 logo-circle-black.svg + + + + structural + hasPart + + URN + urn:roda:premis:file:2012-roda-promo-en.pdf + + + diff --git a/roda-core/roda-core-tests/testng-single.xml b/roda-core/roda-core-tests/testng-single.xml new file mode 100644 index 0000000000..75fa4f1e97 --- /dev/null +++ b/roda-core/roda-core-tests/testng-single.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/roda-core/roda-core-tests/testng.xml b/roda-core/roda-core-tests/testng.xml index aa79fd2552..47c17ed3d7 100644 --- a/roda-core/roda-core-tests/testng.xml +++ b/roda-core/roda-core-tests/testng.xml @@ -1,6 +1,9 @@ + + + diff --git a/roda-core/roda-core/src/main/java/org/roda/core/RodaCoreFactory.java b/roda-core/roda-core/src/main/java/org/roda/core/RodaCoreFactory.java index 9bac9add45..d8269b71ca 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/RodaCoreFactory.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/RodaCoreFactory.java @@ -1045,7 +1045,11 @@ private static SolrClient instantiateSolr(Path solrHome, boolean writeIsAllowed) zkChroot = Optional.empty(); } - CloudSolrClient cloudSolrClient = new CloudSolrClient.Builder(zkHosts, zkChroot).build(); + int zkClientTimeout = getRodaConfiguration().getInt("core.solr.cloud.zk.client.timeout_ms", 600000); + int zkConnectTimeout = getRodaConfiguration().getInt("core.solr.cloud.zk.connect.timeout_ms", 300000); + CloudSolrClient cloudSolrClient = new CloudSolrClient.Builder(zkHosts, zkChroot) + .withZkClientTimeout(zkClientTimeout, TimeUnit.MILLISECONDS) + .withZkConnectTimeout(zkConnectTimeout, TimeUnit.MILLISECONDS).build(); waitForSolrCluster(cloudSolrClient); @@ -1089,7 +1093,8 @@ private static boolean checkSolrCluster(CloudSolrClient cloudSolrClient) cloudSolrClient.connect(connectTimeout, TimeUnit.MILLISECONDS); LOGGER.info("Connected to Solr Cloud"); } catch (TimeoutException e) { - throw new GenericException("Could not connect to Solr Cloud", e); + LOGGER.warn("Timed out waiting for Solr Cloud live nodes (will retry): {}", e.getMessage()); + return false; } ClusterState clusterState = cloudSolrClient.getClusterState(); @@ -1418,7 +1423,7 @@ private static void initializeLdapServer(NodeType nodeType) { private static void indexUsersAndGroupsFromLDAP() throws GenericException { for (User user : getModelService().listUsers()) { getModelService().notifyUserUpdated(user).failOnError(); - if (INSTANTIATE_SOLR) { + if (INSTANTIATE_SOLR && getIndexService() != null) { try { PremisV3Utils.createOrUpdatePremisUserAgentBinary(user.getName(), getModelService(), getIndexService(), true); } catch (ValidationException | NotFoundException | RequestNotValidException | AuthorizationDeniedException diff --git a/roda-core/roda-core/src/main/java/org/roda/core/common/ConfigurableEmailUtility.java b/roda-core/roda-core/src/main/java/org/roda/core/common/ConfigurableEmailUtility.java index 1fb0758038..64b1019ba6 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/common/ConfigurableEmailUtility.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/common/ConfigurableEmailUtility.java @@ -30,7 +30,7 @@ public class ConfigurableEmailUtility { - private static final List DEFAULT_PROPERTIES = Arrays.asList("host", "port", "auth", "starttls.enable"); + private static final List DEFAULT_PROPERTIES = Arrays.asList("auth", "starttls.enable"); private String protocol; private String user; private String password; @@ -89,6 +89,12 @@ public void sendMail(String recipient, String message) throws MessagingException private void createSessionParameters() { boolean hasAuth = false; + String port = RodaCoreFactory.getConfigurationManager().getConfigurationString("core.email.port", "1025"); + props.put("mail.smtp.port", port); + + String host = RodaCoreFactory.getConfigurationManager().getConfigurationString("core.email.host", "localhost"); + props.put("mail.smtp.host", host); + String properties = RodaCoreFactory.getRodaConfigurationAsString("core", "email", "properties"); List propertyList = new ArrayList<>(DEFAULT_PROPERTIES); if (properties != null) { diff --git a/roda-core/roda-core/src/main/java/org/roda/core/common/notifications/EmailNotificationProcessor.java b/roda-core/roda-core/src/main/java/org/roda/core/common/notifications/EmailNotificationProcessor.java index 297ffb3311..21ba379eaf 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/common/notifications/EmailNotificationProcessor.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/common/notifications/EmailNotificationProcessor.java @@ -98,7 +98,7 @@ public Notification processNotification(ModelService model, final Notification n for (String recipient : recipients) { String modifiedBody = getUpdatedMessageBody(model, notification, recipient, template, scope); - String host = RodaCoreFactory.getRodaConfigurationAsString("core", "email", "host"); + String host = RodaCoreFactory.getConfigurationManager().getConfigurationString("core.email.host", "127.0.0.1"); if (StringUtils.isNotBlank(host)) { LOGGER.debug("Sending email ..."); emailUtility.sendMail(recipient, modifiedBody); @@ -111,7 +111,7 @@ public Notification processNotification(ModelService model, final Notification n } } catch (IOException | MessagingException | GenericException e) { processedNotification.setState(NotificationState.FAILED); - LOGGER.debug("Error sending e-mail: {}", e.getMessage()); + LOGGER.error("Error sending e-mail: {}", e.getMessage()); } return processedNotification; } diff --git a/roda-core/roda-core/src/main/java/org/roda/core/config/ConfigurationManager.java b/roda-core/roda-core/src/main/java/org/roda/core/config/ConfigurationManager.java index d2c02f8bac..65351893aa 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/config/ConfigurationManager.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/config/ConfigurationManager.java @@ -409,6 +409,9 @@ public List getRodaConfigurationAsList(String... keyParts) { public String getConfigurationString(String key, String defaultValue) { String envKey = "RODA_" + key.toUpperCase().replace('.', '_'); String value = System.getenv(envKey); + if (value == null) { + value = System.getProperty(envKey); + } if (value == null) { value = rodaConfiguration.getString(key, defaultValue); diff --git a/roda-core/roda-core/src/main/java/org/roda/core/index/IndexModelObserver.java b/roda-core/roda-core/src/main/java/org/roda/core/index/IndexModelObserver.java index ef1052f6d0..0647dba256 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/index/IndexModelObserver.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/index/IndexModelObserver.java @@ -33,6 +33,8 @@ import org.roda.core.data.exceptions.ReturnWithExceptions; import org.roda.core.data.utils.JsonUtils; import org.roda.core.data.v2.IsModelObject; +import org.roda.core.data.v2.IsRODAObject; +import org.roda.core.data.v2.LiteRODAObject; import org.roda.core.data.v2.common.OptionalWithCause; import org.roda.core.data.v2.disposal.confirmation.DisposalConfirmation; import org.roda.core.data.v2.disposal.schedule.DisposalSchedule; @@ -79,8 +81,16 @@ import org.roda.core.index.schema.collections.RiskCollection; import org.roda.core.index.utils.IterableIndexResult; import org.roda.core.index.utils.SolrUtils; +import org.roda.core.model.LiteRODAObjectFactory; import org.roda.core.model.ModelObserver; import org.roda.core.model.ModelService; +import org.roda.core.model.lites.ParsedAIPLite; +import org.roda.core.model.lites.ParsedDIPFileLite; +import org.roda.core.model.lites.ParsedDIPLite; +import org.roda.core.model.lites.ParsedFileLite; +import org.roda.core.model.lites.ParsedLite; +import org.roda.core.model.lites.ParsedPreservationMetadataLite; +import org.roda.core.model.lites.ParsedRepresentationLite; import org.roda.core.storage.Binary; import org.roda.core.storage.fs.FSUtils; import org.roda.core.util.IdUtils; @@ -404,6 +414,17 @@ public ReturnWithExceptions aipUpdated(AIP aip) { return ret; } + @Override + public ReturnWithExceptions aipOnHoldStatusUpdated(AIP aip, boolean status) { + ReturnWithExceptions ret = new ReturnWithExceptions<>(this); + + // change AIP + Map updatedFields = new HashMap<>(); + updatedFields.put(RodaConstants.AIP_DISPOSAL_HOLD_STATUS, status); + SolrUtils.update(index, IndexedAIP.class, aip.getId(), updatedFields, (ModelObserver) this).addTo(ret); + return ret; + } + @Override public ReturnWithExceptions aipUpdatedOn(AIP aip) { ReturnWithExceptions ret = new ReturnWithExceptions<>(this); @@ -1488,4 +1509,70 @@ public ReturnWithExceptions disposalConfirmationDeleted(Str return deleteDocumentFromIndex(DisposalConfirmation.class, confirmationId); } + public ReturnWithExceptions liteRODAObjectCreated(LiteRODAObject liteRODAObject) { + ReturnWithExceptions ret = new ReturnWithExceptions<>(this); + OptionalWithCause liteObject = LiteRODAObjectFactory.get(model, liteRODAObject); + if (liteObject.isPresent()) { + IsRODAObject obj = liteObject.get(); + if (obj instanceof AIP aip) { + ret.add(aipCreated(aip)); + } else if (obj instanceof Representation rep) { + ret.add(representationCreated(rep)); + } else if (obj instanceof File file) { + ret.add(fileCreated(file)); + } else if (obj instanceof DIP dip) { + ret.add(dipCreated(dip, true)); + } else if (obj instanceof DIPFile dipFile) { + ret.add(dipFileCreated(dipFile)); + } else if (obj instanceof PreservationMetadata pm) { + ret.add(preservationMetadataCreated(pm)); + } + } + return ret; + } + + public ReturnWithExceptions liteRODAObjectUpdated(LiteRODAObject liteRODAObject) { + ReturnWithExceptions ret = new ReturnWithExceptions<>(this); + OptionalWithCause liteObject = LiteRODAObjectFactory.get(model, liteRODAObject); + if (liteObject.isPresent()) { + IsRODAObject obj = liteObject.get(); + if (obj instanceof AIP aip) { + ret.add(aipUpdated(aip)); + } else if (obj instanceof Representation rep) { + ret.add(representationUpdated(rep)); + } else if (obj instanceof File file) { + ret.add(fileUpdated(file)); + } else if (obj instanceof DIP dip) { + ret.add(dipUpdated(dip, true)); + } else if (obj instanceof DIPFile dipFile) { + ret.add(dipFileUpdated(dipFile)); + } else if (obj instanceof PreservationMetadata pm) { + ret.add(preservationMetadataUpdated(pm)); + } + } + return ret; + } + + public ReturnWithExceptions liteRODAObjectDeleted(LiteRODAObject liteRODAObject) { + ReturnWithExceptions ret = new ReturnWithExceptions<>(this); + OptionalWithCause parsed = ParsedLite.parse(liteRODAObject); + if (parsed.isPresent()) { + ParsedLite lite = parsed.get(); + if (lite instanceof ParsedAIPLite aip) { + ret.add(aipDeleted(aip.getId(), true)); + + } else if (lite instanceof ParsedRepresentationLite rep) { + ret.add(representationDeleted(rep.getAipId(), rep.getId(), true)); + } else if (lite instanceof ParsedFileLite file) { + ret.add(fileDeleted(file.getAipId(), file.getRepresentationId(), file.getDirectoryPath(), file.getId(), true)); + } else if (lite instanceof ParsedDIPLite dip) { + ret.add(dipDeleted(dip.getId(), true)); + } else if (lite instanceof ParsedDIPFileLite dipFile) { + ret.add(dipFileDeleted(dipFile.getId(), dipFile.getDirectoryPath(), dipFile.getFileId())); + } else if (lite instanceof ParsedPreservationMetadataLite pm) { + ret.add(preservationMetadataDeleted(new PreservationMetadata(pm.getId(), pm.getType()))); + } + } + return ret; + } } diff --git a/roda-core/roda-core/src/main/java/org/roda/core/model/DefaultModelService.java b/roda-core/roda-core/src/main/java/org/roda/core/model/DefaultModelService.java index eb3c06bd5f..7da4af2b04 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/model/DefaultModelService.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/model/DefaultModelService.java @@ -12,9 +12,12 @@ import static org.roda.core.common.DownloadUtils.ZIP_PATH_DELIMITER; import java.io.BufferedOutputStream; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.io.SequenceInputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.DirectoryStream; import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; @@ -43,12 +46,15 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.roda.core.RodaCoreFactory; import org.roda.core.common.JwtUtils; import org.roda.core.common.PremisV3Utils; +import org.roda.core.common.ProvidesInputStream; import org.roda.core.common.ReturnWithExceptionsWrapper; import org.roda.core.common.dips.DIPUtils; import org.roda.core.common.iterables.CloseableIterable; @@ -165,6 +171,7 @@ import org.roda.core.storage.EmptyClosableIterable; import org.roda.core.storage.Entity; import org.roda.core.storage.ExternalFileManifestContentPayload; +import org.roda.core.storage.InputStreamContentPayload; import org.roda.core.storage.JsonContentPayload; import org.roda.core.storage.Resource; import org.roda.core.storage.StorageService; @@ -176,6 +183,9 @@ import org.roda.core.util.HTTPUtility; import org.roda.core.util.IdUtils; import org.roda.core.util.RESTClientUtility; +import org.roda.core.config.SpringContext; +import org.roda.core.repository.job.JobRepository; +import org.roda.core.repository.job.ReportRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -194,13 +204,42 @@ public class DefaultModelService implements ModelService { private final StorageService storage; private final EventsManager eventsManager; private final NodeType nodeType; + // Observer + private final List observers; private String instanceId = ""; private Object logFileLock = new Object(); - private long entryLogLineNumber = -1; - // Observer - private final List observers; + // Lazy-loaded JPA repositories for hybrid Job/Report persistence + private JobRepository jobRepository; + private ReportRepository reportRepository; + + /** + * Lazily retrieves the JobRepository bean from Spring context. + */ + private JobRepository getJobRepository() { + if (jobRepository == null && SpringContext.isContextInitialized()) { + jobRepository = SpringContext.getBean(JobRepository.class); + } + return jobRepository; + } + + /** + * Lazily retrieves the ReportRepository bean from Spring context. + */ + private ReportRepository getReportRepository() { + if (reportRepository == null && SpringContext.isContextInitialized()) { + reportRepository = SpringContext.getBean(ReportRepository.class); + } + return reportRepository; + } + + /** + * Checks if the JPA repositories are available (Spring context is initialized). + */ + private boolean isJpaAvailable() { + return SpringContext.isContextInitialized(); + } public DefaultModelService(StorageService storage, EventsManager eventsManager, NodeType nodeType, String instanceId) { @@ -216,6 +255,18 @@ public DefaultModelService(StorageService storage, EventsManager eventsManager, } } + private static void clearSpecificIndexes(IndexService index, Class objectClass, + IsModelObject rodaObject) throws AuthorizationDeniedException { + if (AIP.class.equals(objectClass)) { + List ids = Arrays.asList(rodaObject.getId()); + index.delete(IndexedRepresentation.class, + new Filter(new OneOfManyFilterParameter(RodaConstants.REPRESENTATION_AIP_ID, ids))); + index.delete(IndexedFile.class, new Filter(new OneOfManyFilterParameter(RodaConstants.FILE_AIP_ID, ids))); + index.delete(IndexedPreservationEvent.class, + new Filter(new OneOfManyFilterParameter(RodaConstants.PRESERVATION_EVENT_AIP_ID, ids))); + } + } + private void ensureAllContainersExist() { try { createContainerIfNotExists(RodaConstants.STORAGE_CONTAINER_AIP); @@ -618,6 +669,14 @@ public AIP updateAIP(AIP aip, String updatedBy) return aip; } + @Override + public AIP updateAIPOnHoldStatus(AIP aip, boolean status) throws AuthorizationDeniedException, GenericException { + RodaCoreFactory.checkIfWriteIsAllowedAndIfFalseThrowException(nodeType); + + notifyAipOnHoldStatusUpdated(aip, status).failOnError(); + return aip; + } + @Override public AIP updateAIPState(AIP aip, String updatedBy) throws GenericException, NotFoundException, RequestNotValidException, AuthorizationDeniedException { @@ -2862,17 +2921,71 @@ public void createOrUpdateJob(Job job) if (job.getInstanceId() == null) { job.setInstanceId(RODAInstanceUtils.getLocalInstanceIdentifier()); } - // create or update job in storage + + // Check if JPA is available and determine persistence strategy + if (isJpaAvailable() && getJobRepository() != null) { + if (Job.isFinalState(job.getState())) { + // Job is in final state - flush to storage and remove from DB + flushJobToStorage(job); + } else { + // Job is running - save to database only + getJobRepository().save(job); + } + } else { + // Fallback to storage-only persistence + String jobAsJson = JsonUtils.getJsonFromObject(job); + StoragePath jobPath = ModelUtils.getJobStoragePath(job.getId()); + storage.updateBinaryContent(jobPath, new StringContentPayload(jobAsJson), false, true, false, null); + } + // index it + notifyJobCreatedOrUpdated(job, false).failOnError(); + } + + /** + * Flushes a job and its reports from the database to the file storage. + * This method is called when a job reaches a final state. + */ + private void flushJobToStorage(Job job) + throws RequestNotValidException, GenericException, NotFoundException, AuthorizationDeniedException { + // Write job to storage String jobAsJson = JsonUtils.getJsonFromObject(job); StoragePath jobPath = ModelUtils.getJobStoragePath(job.getId()); storage.updateBinaryContent(jobPath, new StringContentPayload(jobAsJson), false, true, false, null); - // index it - notifyJobCreatedOrUpdated(job, false).failOnError(); + + // Flush all reports for this job from DB to storage + if (getReportRepository() != null) { + List dbReports = getReportRepository().findByJobId(job.getId()); + for (Report report : dbReports) { + String reportAsJson = JsonUtils.getJsonFromObject(report); + StoragePath reportPath = ModelUtils.getJobReportStoragePath(report.getJobId(), report.getId()); + storage.updateBinaryContent(reportPath, new StringContentPayload(reportAsJson), false, true, false, null); + } + // Delete reports from DB + getReportRepository().deleteByJobId(job.getId()); + } + + // Delete job from DB + JobRepository jobRepo = getJobRepository(); + if (jobRepo != null && jobRepo.existsById(job.getId())) { + jobRepo.deleteById(job.getId()); + } } @Override public Job retrieveJob(String jobId) throws RequestNotValidException, GenericException, NotFoundException, AuthorizationDeniedException { + // Try to fetch from database first (for running jobs) + if (isJpaAvailable()) { + JobRepository jobRepo = getJobRepository(); + if (jobRepo != null) { + Optional dbJob = jobRepo.findById(jobId); + if (dbJob.isPresent()) { + return dbJob.get(); + } + } + } + + // Fallback to storage (for completed/archived jobs) StoragePath jobPath = ModelUtils.getJobStoragePath(jobId); Binary binary = storage.getBinary(jobPath); Job ret; @@ -2888,6 +3001,21 @@ public Job retrieveJob(String jobId) public CloseableIterable> listJobReports(String jobId) throws RequestNotValidException, AuthorizationDeniedException, NotFoundException, GenericException { + // Check if job exists in database (running job) + if (isJpaAvailable()) { + JobRepository jobRepo = getJobRepository(); + ReportRepository reportRepo = getReportRepository(); + if (jobRepo != null && reportRepo != null && jobRepo.existsById(jobId)) { + // Return reports from database + List dbReports = reportRepo.findByJobId(jobId); + List> wrappedReports = dbReports.stream() + .map(OptionalWithCause::of) + .collect(Collectors.toList()); + return CloseableIterables.fromList(wrappedReports); + } + } + + // Fallback to storage final CloseableIterable resourcesIterable = storage .listResourcesUnderContainer(ModelUtils.getJobReportsStoragePath(jobId), false); return ResourceParseUtils.convert(getStorage(), resourcesIterable, Report.class); @@ -2898,10 +3026,40 @@ public void deleteJob(String jobId) throws NotFoundException, GenericException, AuthorizationDeniedException, RequestNotValidException { RodaCoreFactory.checkIfWriteIsAllowedAndIfFalseThrowException(nodeType); - StoragePath jobPath = ModelUtils.getJobStoragePath(jobId); + boolean deletedFromDb = false; - // remove it from storage - storage.deleteResource(jobPath); + // Try to delete from database first (for running jobs) + if (isJpaAvailable()) { + JobRepository jobRepo = getJobRepository(); + ReportRepository reportRepo = getReportRepository(); + if (jobRepo != null && jobRepo.existsById(jobId)) { + // Delete reports from DB + if (reportRepo != null) { + reportRepo.deleteByJobId(jobId); + } + // Delete job from DB + jobRepo.deleteById(jobId); + deletedFromDb = true; + } + } + + // Also try to delete from storage (for archived jobs or cleanup) + try { + StoragePath jobPath = ModelUtils.getJobStoragePath(jobId); + storage.deleteResource(jobPath); + // Also try to delete job reports directory from storage + try { + StoragePath reportsPath = ModelUtils.getJobReportsStoragePath(jobId); + storage.deleteResource(reportsPath); + } catch (NotFoundException e) { + // Reports directory may not exist, ignore + } + } catch (NotFoundException e) { + // If not found in storage and also not deleted from DB, propagate the exception + if (!deletedFromDb) { + throw e; + } + } // remove it from index notifyJobDeleted(jobId).failOnError(); @@ -2910,6 +3068,18 @@ public void deleteJob(String jobId) @Override public Report retrieveJobReport(String jobId, String jobReportId) throws RequestNotValidException, GenericException, NotFoundException, AuthorizationDeniedException { + // Try to fetch from database first (for running jobs) + if (isJpaAvailable()) { + ReportRepository reportRepo = getReportRepository(); + if (reportRepo != null) { + Optional dbReport = reportRepo.findById(jobReportId); + if (dbReport.isPresent()) { + return dbReport.get(); + } + } + } + + // Fallback to storage StoragePath jobReportPath = ModelUtils.getJobReportStoragePath(jobId, jobReportId); Binary binary = storage.getBinary(jobReportPath); Report ret; @@ -2937,14 +3107,39 @@ public void createOrUpdateJobReport(Report jobReport, Job cachedJob) jobReport.setInstanceId(RODAInstanceUtils.getLocalInstanceIdentifier()); - // create job report in storage + // Handle ID change + String newId = IdUtils.getJobReportId(jobReport.getJobId(), jobReport.getSourceObjectId(), + jobReport.getOutcomeObjectId()); + String oldId = null; + if (!newId.equals(jobReport.getId())) { + oldId = jobReport.getId(); + jobReport.setId(newId); + } + + // Check if job exists in database (running job) - use DB for reports + if (isJpaAvailable()) { + JobRepository jobRepo = getJobRepository(); + ReportRepository reportRepo = getReportRepository(); + if (jobRepo != null && reportRepo != null && jobRepo.existsById(jobReport.getJobId())) { + try { + // Delete old report from DB if ID changed + if (oldId != null && reportRepo.existsById(oldId)) { + reportRepo.deleteById(oldId); + notifyJobReportDeleted(oldId); + } + // Save to database + reportRepo.save(jobReport); + // index it + notifyJobReportCreatedOrUpdated(jobReport, cachedJob).failOnError(); + } catch (Exception e) { + LOGGER.error("Error creating/updating job report in database", e); + } + return; + } + } + // Fallback to storage persistence try { - // if job report changed id, set it and remove old report - String newId = IdUtils.getJobReportId(jobReport.getJobId(), jobReport.getSourceObjectId(), - jobReport.getOutcomeObjectId()); - if (!newId.equals(jobReport.getId())) { - String oldId = jobReport.getId(); - jobReport.setId(newId); + if (oldId != null) { storage.deleteResource(ModelUtils.getJobReportStoragePath(jobReport.getJobId(), oldId)); notifyJobReportDeleted(oldId); } @@ -2967,14 +3162,39 @@ public void createOrUpdateJobReport(Report jobReport, IndexedJob indexJob) jobReport.setInstanceId(RODAInstanceUtils.getLocalInstanceIdentifier()); - // create job report in storage + // Handle ID change + String newId = IdUtils.getJobReportId(jobReport.getJobId(), jobReport.getSourceObjectId(), + jobReport.getOutcomeObjectId()); + String oldId = null; + if (!newId.equals(jobReport.getId())) { + oldId = jobReport.getId(); + jobReport.setId(newId); + } + + // Check if job exists in database (running job) - use DB for reports + if (isJpaAvailable()) { + JobRepository jobRepo = getJobRepository(); + ReportRepository reportRepo = getReportRepository(); + if (jobRepo != null && reportRepo != null && jobRepo.existsById(jobReport.getJobId())) { + try { + // Delete old report from DB if ID changed + if (oldId != null && reportRepo.existsById(oldId)) { + reportRepo.deleteById(oldId); + notifyJobReportDeleted(oldId); + } + // Save to database + reportRepo.save(jobReport); + // index it + notifyJobReportCreatedOrUpdated(jobReport, indexJob).failOnError(); + } catch (Exception e) { + LOGGER.error("Error creating/updating job report in database", e); + } + return; + } + } + // Fallback to storage persistence try { - // if job report changed id, set it and remove old report - String newId = IdUtils.getJobReportId(jobReport.getJobId(), jobReport.getSourceObjectId(), - jobReport.getOutcomeObjectId()); - if (!newId.equals(jobReport.getId())) { - String oldId = jobReport.getId(); - jobReport.setId(newId); + if (oldId != null) { storage.deleteResource(ModelUtils.getJobReportStoragePath(jobReport.getJobId(), oldId)); notifyJobReportDeleted(oldId); } @@ -3671,6 +3891,22 @@ public void createSubmission(Path submissionPath, String aipId) throws AlreadyEx storage.createBinary(submissionStoragePath, new FSPathContentPayload(submissionPath), false); } + @Override + public void createMetsFile(String aipId, String repId, ContentPayload metsPayload) throws RequestNotValidException, + GenericException, AlreadyExistsException, AuthorizationDeniedException, NotFoundException { + RodaCoreFactory.checkIfWriteIsAllowedAndIfFalseThrowException(nodeType); + + StoragePath metsOutPut = null; + + if (repId != null) { + metsOutPut = ModelUtils.getMetsStoragePath(aipId, repId); + } else { + metsOutPut = ModelUtils.getMetsStoragePath(aipId, null); + } + storage.createBinary(metsOutPut, metsPayload, false); + + } + private Directory getDocumentationDirectory(String aipId) throws RequestNotValidException, NotFoundException, GenericException, AuthorizationDeniedException { return storage.getDirectory(ModelUtils.getDocumentationStoragePath(aipId)); @@ -3845,7 +4081,35 @@ public CloseableIterable> list(Cla } else if (DescriptiveMetadata.class.equals(objectClass)) { ret = listDescriptiveMetadata(); } else if (Report.class.equals(objectClass)) { - ret = ResourceParseUtils.convert(getStorage(), listReportResources(), objectClass); + // Include both DB reports (for running jobs) and storage reports (for completed jobs) + CloseableIterable> storageReports = ResourceParseUtils.convert(getStorage(), + listReportResources(), Report.class); + if (isJpaAvailable() && getReportRepository() != null) { + List dbReports = getReportRepository().findAll(); + List> wrappedDbReports = dbReports.stream() + .map(OptionalWithCause::of) + .collect(Collectors.toList()); + CloseableIterable> dbIterable = CloseableIterables.fromList(wrappedDbReports); + ret = CloseableIterables.concat(dbIterable, storageReports); + } else { + ret = storageReports; + } + } else if (Job.class.equals(objectClass)) { + // Include both DB jobs (running) and storage jobs (completed/archived) + StoragePath containerPath = ModelUtils.getContainerPath(objectClass); + final CloseableIterable resourcesIterable = storage.listResourcesUnderContainer(containerPath, false); + CloseableIterable> storageJobs = ResourceParseUtils.convert(getStorage(), + resourcesIterable, Job.class); + if (isJpaAvailable() && getJobRepository() != null) { + List dbJobs = getJobRepository().findAll(); + List> wrappedDbJobs = dbJobs.stream() + .map(OptionalWithCause::of) + .collect(Collectors.toList()); + CloseableIterable> dbIterable = CloseableIterables.fromList(wrappedDbJobs); + ret = CloseableIterables.concat(dbIterable, storageJobs); + } else { + ret = storageJobs; + } } else { StoragePath containerPath = ModelUtils.getContainerPath(objectClass); final CloseableIterable resourcesIterable = storage.listResourcesUnderContainer(containerPath, false); @@ -3880,12 +4144,42 @@ public CloseableIterable> storageReports = ResourceParseUtils.convertLite(getStorage(), + listReportResources(), objectClass); + if (isJpaAvailable() && getReportRepository() != null) { + List dbReports = getReportRepository().findAll(); + List> wrappedDbReports = dbReports.stream() + .map(OptionalWithCause::of) + .collect(Collectors.toList()); + CloseableIterable> dbIterable = LiteRODAObjectFactory + .transformIntoLite(CloseableIterables.fromList(wrappedDbReports)); + ret = CloseableIterables.concat(dbIterable, storageReports); + } else { + ret = storageReports; + } /* * } else if (DisposalConfirmation.class.equals(objectClass)) { ret = * ResourceParseUtils.convertLite(getStorage(), * ResourceListUtils.listDisposalConfirmationResources(storage), objectClass); */ + } else if (Job.class.equals(objectClass)) { + // Include both DB jobs (running) and storage jobs (completed/archived) + StoragePath containerPath = ModelUtils.getContainerPath(objectClass); + final CloseableIterable resourcesIterable = storage.listResourcesUnderContainer(containerPath, false); + CloseableIterable> storageJobs = ResourceParseUtils.convertLite(getStorage(), + resourcesIterable, objectClass); + if (isJpaAvailable() && getJobRepository() != null) { + List dbJobs = getJobRepository().findAll(); + List> wrappedDbJobs = dbJobs.stream() + .map(OptionalWithCause::of) + .collect(Collectors.toList()); + CloseableIterable> dbIterable = LiteRODAObjectFactory + .transformIntoLite(CloseableIterables.fromList(wrappedDbJobs)); + ret = CloseableIterables.concat(dbIterable, storageJobs); + } else { + ret = storageJobs; + } } else { StoragePath containerPath = ModelUtils.getContainerPath(objectClass); final CloseableIterable resourcesIterable = storage.listResourcesUnderContainer(containerPath, false); @@ -4530,51 +4824,131 @@ public DisposalConfirmation retrieveDisposalConfirmation(String disposalConfirma } @Override - public void addDisposalHoldEntry(String disposalConfirmationId, DisposalHold disposalHold) - throws GenericException, RequestNotValidException { - StoragePath confirmationStoragePath = ModelUtils.getDisposalConfirmationStoragePath(disposalConfirmationId); - Path confirmationPath = FSUtils.getEntityPath(RodaCoreFactory.getStoragePath(), confirmationStoragePath); + public void addDisposalHoldEntry(String disposalConfirmationId, DisposalHold disposalHold) throws GenericException, + RequestNotValidException, AuthorizationDeniedException, NotFoundException, AlreadyExistsException { + StoragePath confirmationStoragePath = ModelUtils + .getDisposalHoldsFromDisposalConfirmationStoragePath(disposalConfirmationId); + + if (!storage.exists(confirmationStoragePath)) { + storage.createBinary(confirmationStoragePath, new StringContentPayload(JsonUtils.getJsonFromObject(disposalHold)), + false); + } else { + Binary binary = storage.getBinary(confirmationStoragePath); - Path file = FSUtils.createFile(confirmationPath, - RodaConstants.STORAGE_DIRECTORY_DISPOSAL_CONFIRMATION_HOLDS_FILENAME, true, true); + ProvidesInputStream streamProvider = () -> { + // We let the storage framework handle closing this when it finishes reading + InputStream originalStream = binary.getContent().createInputStream(); + String jsonFromObject = JsonUtils.getJsonFromObject(disposalHold); + byte[] pojoBytes = jsonFromObject.getBytes(StandardCharsets.UTF_8); + byte[] newline = "\n".getBytes(StandardCharsets.UTF_8); - JsonUtils.appendObjectToFile(disposalHold, file); + InputStream newlineStream = new ByteArrayInputStream(newline); + InputStream pojoStream = new ByteArrayInputStream(pojoBytes); + + InputStream firstJoin = new SequenceInputStream(originalStream, newlineStream); + return new SequenceInputStream(firstJoin, pojoStream); + }; + + storage.updateBinaryContent(confirmationStoragePath, new InputStreamContentPayload(streamProvider), false, false, + false, null); + } } @Override public void addDisposalHoldTransitiveEntry(String disposalConfirmationId, DisposalHold transitiveDisposalHold) - throws RequestNotValidException, GenericException { - StoragePath confirmationStoragePath = ModelUtils.getDisposalConfirmationStoragePath(disposalConfirmationId); - Path confirmationPath = FSUtils.getEntityPath(RodaCoreFactory.getStoragePath(), confirmationStoragePath); + throws RequestNotValidException, GenericException, AuthorizationDeniedException, NotFoundException, + AlreadyExistsException { + StoragePath confirmationStoragePath = ModelUtils + .getDisposalTransitiveHoldsFromDisposalConfirmationStoragePath(disposalConfirmationId); - Path file = FSUtils.createFile(confirmationPath, - RodaConstants.STORAGE_DIRECTORY_DISPOSAL_CONFIRMATION_TRANSITIVE_HOLDS_FILENAME, true, true); + if (!storage.exists(confirmationStoragePath)) { + storage.createBinary(confirmationStoragePath, + new StringContentPayload(JsonUtils.getJsonFromObject(transitiveDisposalHold)), false); + } else { + Binary binary = storage.getBinary(confirmationStoragePath); - JsonUtils.appendObjectToFile(transitiveDisposalHold, file); + ProvidesInputStream streamProvider = () -> { + // We let the storage framework handle closing this when it finishes reading + InputStream originalStream = binary.getContent().createInputStream(); + String jsonFromObject = JsonUtils.getJsonFromObject(transitiveDisposalHold); + byte[] pojoBytes = jsonFromObject.getBytes(StandardCharsets.UTF_8); + byte[] newline = "\n".getBytes(StandardCharsets.UTF_8); + + InputStream newlineStream = new ByteArrayInputStream(newline); + InputStream pojoStream = new ByteArrayInputStream(pojoBytes); + + InputStream firstJoin = new SequenceInputStream(originalStream, newlineStream); + return new SequenceInputStream(firstJoin, pojoStream); + }; + + storage.updateBinaryContent(confirmationStoragePath, new InputStreamContentPayload(streamProvider), false, false, + false, null); + } } @Override public void addDisposalScheduleEntry(String disposalConfirmationId, DisposalSchedule disposalSchedule) - throws RequestNotValidException, GenericException { - StoragePath confirmationStoragePath = ModelUtils.getDisposalConfirmationStoragePath(disposalConfirmationId); - Path confirmationPath = FSUtils.getEntityPath(RodaCoreFactory.getStoragePath(), confirmationStoragePath); + throws RequestNotValidException, GenericException, AuthorizationDeniedException, NotFoundException, + AlreadyExistsException { + + StoragePath confirmationStoragePath = ModelUtils + .getDisposalSchedulesFromDisposalConfirmationStoragePath(disposalConfirmationId); + + if (!storage.exists(confirmationStoragePath)) { + storage.createBinary(confirmationStoragePath, + new StringContentPayload(JsonUtils.getJsonFromObject(disposalSchedule)), false); + } else { + Binary binary = storage.getBinary(confirmationStoragePath); + + ProvidesInputStream streamProvider = () -> { + // We let the storage framework handle closing this when it finishes reading + InputStream originalStream = binary.getContent().createInputStream(); + String jsonFromObject = JsonUtils.getJsonFromObject(disposalSchedule); + byte[] pojoBytes = jsonFromObject.getBytes(StandardCharsets.UTF_8); + byte[] newline = "\n".getBytes(StandardCharsets.UTF_8); + + InputStream newlineStream = new ByteArrayInputStream(newline); + InputStream pojoStream = new ByteArrayInputStream(pojoBytes); - Path file = FSUtils.createFile(confirmationPath, - RodaConstants.STORAGE_DIRECTORY_DISPOSAL_CONFIRMATION_SCHEDULES_FILENAME, true, true); + InputStream firstJoin = new SequenceInputStream(originalStream, newlineStream); + return new SequenceInputStream(firstJoin, pojoStream); + }; - JsonUtils.appendObjectToFile(disposalSchedule, file); + storage.updateBinaryContent(confirmationStoragePath, new InputStreamContentPayload(streamProvider), false, false, + false, null); + } } @Override public void addAIPEntry(String disposalConfirmationId, DisposalConfirmationAIPEntry entry) - throws RequestNotValidException, GenericException { - StoragePath confirmationStoragePath = ModelUtils.getDisposalConfirmationStoragePath(disposalConfirmationId); - Path confirmationPath = FSUtils.getEntityPath(RodaCoreFactory.getStoragePath(), confirmationStoragePath); + throws RequestNotValidException, GenericException, AuthorizationDeniedException, NotFoundException, + AlreadyExistsException { + StoragePath confirmationStoragePath = ModelUtils + .getDisposalAipsFromDisposalConfirmationStoragePath(disposalConfirmationId); + + if (!storage.exists(confirmationStoragePath)) { + storage.createBinary(confirmationStoragePath, new StringContentPayload(JsonUtils.getJsonFromObject(entry)), + false); + } else { + Binary binary = storage.getBinary(confirmationStoragePath); - Path file = FSUtils.createFile(confirmationPath, - RodaConstants.STORAGE_DIRECTORY_DISPOSAL_CONFIRMATION_AIPS_FILENAME, true, true); + ProvidesInputStream streamProvider = () -> { + // We let the storage framework handle closing this when it finishes reading + InputStream originalStream = binary.getContent().createInputStream(); + String jsonFromObject = JsonUtils.getJsonFromObject(entry); + byte[] pojoBytes = jsonFromObject.getBytes(StandardCharsets.UTF_8); + byte[] newline = "\n".getBytes(StandardCharsets.UTF_8); - JsonUtils.appendObjectToFile(entry, file); + InputStream newlineStream = new ByteArrayInputStream(newline); + InputStream pojoStream = new ByteArrayInputStream(pojoBytes); + + InputStream firstJoin = new SequenceInputStream(originalStream, newlineStream); + return new SequenceInputStream(firstJoin, pojoStream); + }; + + storage.updateBinaryContent(confirmationStoragePath, new InputStreamContentPayload(streamProvider), false, false, + false, null); + } } @Override @@ -4726,6 +5100,10 @@ public DisposalRule retrieveDisposalRule(String disposalRuleId) return ret; } + /************************************ + * Disposal bin related + ************************************/ + @Override public DisposalRules listDisposalRules() throws RequestNotValidException, GenericException, AuthorizationDeniedException, IOException { @@ -4746,10 +5124,6 @@ public DisposalRules listDisposalRules() return disposalRules; } - /************************************ - * Disposal bin related - ************************************/ - /************************************ * Distributed instances system related ************************************/ @@ -5060,6 +5434,18 @@ private ReturnWithExceptionsWrapper notifyObserversSafely(Function observer.liteRODAObjectCreated(object)); + } + + public ReturnWithExceptionsWrapper notifyLiteRodaObjectUpdated(LiteRODAObject object) { + return notifyObserversSafely(observer -> observer.liteRODAObjectUpdated(object)); + } + + public ReturnWithExceptionsWrapper notifyLiteRodaObjectDeleted(LiteRODAObject object) { + return notifyObserversSafely(observer -> observer.liteRODAObjectUpdated(object)); + } + @Override public ReturnWithExceptionsWrapper notifyAipCreated(AIP aip) { return notifyObserversSafely(observer -> observer.aipCreated(aip)); @@ -5075,6 +5461,11 @@ public ReturnWithExceptionsWrapper notifyAipUpdatedOnChanged(AIP aip) { return notifyObserversSafely(observer -> observer.aipUpdatedOn(aip)); } + @Override + public ReturnWithExceptionsWrapper notifyAipOnHoldStatusUpdated(AIP aip, boolean status) { + return notifyObserversSafely(observer -> observer.aipOnHoldStatusUpdated(aip, status)); + } + @Override public ReturnWithExceptionsWrapper notifyAipDestroyed(AIP aip) { return notifyObserversSafely(observer -> observer.aipDestroyed(aip)); @@ -5631,26 +6022,27 @@ private void reindexResource(IndexService index, Resource resource) } } - private static void clearSpecificIndexes(IndexService index, Class objectClass, - IsModelObject rodaObject) throws AuthorizationDeniedException { - if (AIP.class.equals(objectClass)) { - List ids = Arrays.asList(rodaObject.getId()); - index.delete(IndexedRepresentation.class, - new Filter(new OneOfManyFilterParameter(RodaConstants.REPRESENTATION_AIP_ID, ids))); - index.delete(IndexedFile.class, new Filter(new OneOfManyFilterParameter(RodaConstants.FILE_AIP_ID, ids))); - index.delete(IndexedPreservationEvent.class, - new Filter(new OneOfManyFilterParameter(RodaConstants.PRESERVATION_EVENT_AIP_ID, ids))); - } - } - @Override public void exportAll(StorageService toStorage) { // TODO } @Override - public void importObject(IsRODAObject object, StorageService fromStorage) { - // TODO + public void importObject(ModelService fromModel, LiteRODAObject object, boolean replaceExisting) + throws RequestNotValidException, GenericException, NotFoundException, AuthorizationDeniedException, + AlreadyExistsException { + StoragePath toObjectPath = ModelUtils.getStoragePath(object); + boolean existsBeforeImport = getStorage().exists(toObjectPath); + + getStorage().importObject(fromModel.getStorage(), object, toObjectPath, replaceExisting); + + boolean notifyUpdate = existsBeforeImport && replaceExisting; + + if (notifyUpdate) { + notifyLiteRodaObjectUpdated(object); + } else { + notifyLiteRodaObjectCreated(object); + } } @Override diff --git a/roda-core/roda-core/src/main/java/org/roda/core/model/DefaultTransactionalModelService.java b/roda-core/roda-core/src/main/java/org/roda/core/model/DefaultTransactionalModelService.java index 0d701343d3..14c4e31a3d 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/model/DefaultTransactionalModelService.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/model/DefaultTransactionalModelService.java @@ -338,6 +338,19 @@ public AIP updateAIP(AIP aip, String updatedBy) } } + @Override + public AIP updateAIPOnHoldStatus(AIP aip, boolean status) throws AuthorizationDeniedException, GenericException { + TransactionalModelOperationLog operationLog = operationRegistry.registerUpdateOperationForAIP(aip.getId()); + try { + AIP ret = getModelService().updateAIPOnHoldStatus(aip, status); + operationRegistry.updateOperationState(operationLog, OperationState.SUCCESS); + return ret; + } catch (GenericException | AuthorizationDeniedException e) { + operationRegistry.updateOperationState(operationLog, OperationState.FAILURE); + throw e; + } + } + @Override public AIP updateAIPState(AIP aip, String updatedBy) throws GenericException, NotFoundException, RequestNotValidException, AuthorizationDeniedException { @@ -2288,6 +2301,10 @@ public BinaryVersion revertRiskVersion(String riskId, String versionId, Map directoryPath, String fileId, ContentPayload contentPayload) throws RequestNotValidException, GenericException, AlreadyExistsException, @@ -3239,14 +3270,15 @@ public DisposalConfirmation retrieveDisposalConfirmation(String disposalConfirma } @Override - public void addDisposalHoldEntry(String disposalConfirmationId, DisposalHold disposalHold) - throws GenericException, RequestNotValidException { + public void addDisposalHoldEntry(String disposalConfirmationId, DisposalHold disposalHold) throws GenericException, + RequestNotValidException, AuthorizationDeniedException, NotFoundException, AlreadyExistsException { TransactionalModelOperationLog operationLog = operationRegistry .registerOperationForDisposalConfirmation(disposalConfirmationId, OperationType.READ); try { getModelService().addDisposalHoldEntry(disposalConfirmationId, disposalHold); operationRegistry.updateOperationState(operationLog, OperationState.SUCCESS); - } catch (GenericException | RequestNotValidException e) { + } catch (GenericException | RequestNotValidException | AuthorizationDeniedException | NotFoundException + | AlreadyExistsException e) { operationRegistry.updateOperationState(operationLog, OperationState.FAILURE); throw e; } @@ -3254,13 +3286,15 @@ public void addDisposalHoldEntry(String disposalConfirmationId, DisposalHold dis @Override public void addDisposalHoldTransitiveEntry(String disposalConfirmationId, DisposalHold transitiveDisposalHold) - throws RequestNotValidException, GenericException { + throws RequestNotValidException, GenericException, AuthorizationDeniedException, NotFoundException, + AlreadyExistsException { TransactionalModelOperationLog operationLog = operationRegistry .registerOperationForDisposalConfirmation(disposalConfirmationId, OperationType.READ); try { getModelService().addDisposalHoldTransitiveEntry(disposalConfirmationId, transitiveDisposalHold); operationRegistry.updateOperationState(operationLog, OperationState.SUCCESS); - } catch (RequestNotValidException | GenericException e) { + } catch (RequestNotValidException | GenericException | AuthorizationDeniedException | NotFoundException + | AlreadyExistsException e) { operationRegistry.updateOperationState(operationLog, OperationState.FAILURE); throw e; } @@ -3268,13 +3302,15 @@ public void addDisposalHoldTransitiveEntry(String disposalConfirmationId, Dispos @Override public void addDisposalScheduleEntry(String disposalConfirmationId, DisposalSchedule disposalSchedule) - throws RequestNotValidException, GenericException { + throws RequestNotValidException, GenericException, AuthorizationDeniedException, NotFoundException, + AlreadyExistsException { TransactionalModelOperationLog operationLog = operationRegistry .registerOperationForDisposalConfirmation(disposalConfirmationId, OperationType.UPDATE); try { getModelService().addDisposalScheduleEntry(disposalConfirmationId, disposalSchedule); operationRegistry.updateOperationState(operationLog, OperationState.SUCCESS); - } catch (RequestNotValidException | GenericException e) { + } catch (RequestNotValidException | GenericException | AuthorizationDeniedException | NotFoundException + | AlreadyExistsException e) { operationRegistry.updateOperationState(operationLog, OperationState.FAILURE); throw e; } @@ -3282,13 +3318,15 @@ public void addDisposalScheduleEntry(String disposalConfirmationId, DisposalSche @Override public void addAIPEntry(String disposalConfirmationId, DisposalConfirmationAIPEntry entry) - throws RequestNotValidException, GenericException { + throws RequestNotValidException, GenericException, AuthorizationDeniedException, AlreadyExistsException, + NotFoundException { TransactionalModelOperationLog operationLog = operationRegistry .registerOperationForDisposalConfirmation(disposalConfirmationId, OperationType.UPDATE); try { getModelService().addAIPEntry(disposalConfirmationId, entry); operationRegistry.updateOperationState(operationLog, OperationState.SUCCESS); - } catch (RequestNotValidException | GenericException e) { + } catch (RequestNotValidException | GenericException | AuthorizationDeniedException | NotFoundException + | AlreadyExistsException e) { operationRegistry.updateOperationState(operationLog, OperationState.FAILURE); throw e; } @@ -3924,10 +3962,23 @@ public void exportAll(StorageService toStorage) { } @Override - public void importObject(IsRODAObject object, StorageService fromStorage) { - TransactionalModelOperationLog operationLog = operationRegistry.registerOperation(object, OperationType.UPDATE); - getModelService().importObject(object, fromStorage); - operationRegistry.updateOperationState(operationLog, OperationState.SUCCESS); + public void importObject(ModelService fromModel, LiteRODAObject object, boolean replaceExisting) + throws RequestNotValidException, GenericException, NotFoundException, AuthorizationDeniedException, + AlreadyExistsException { + TransactionalModelOperationLog operationLog; + if (replaceExisting) { + operationLog = operationRegistry.registerOperation(object.getInfo(), OperationType.CREATE_OR_UPDATE); + } else { + operationLog = operationRegistry.registerOperation(object.getInfo(), OperationType.CREATE); + } + try { + getModelService().importObject(fromModel, object, replaceExisting); + operationRegistry.updateOperationState(operationLog, OperationState.SUCCESS); + } catch (RequestNotValidException | NotFoundException | AuthorizationDeniedException | AlreadyExistsException + | GenericException e) { + operationRegistry.updateOperationState(operationLog, OperationState.FAILURE); + throw e; + } } @Override @@ -4166,6 +4217,11 @@ public ReturnWithExceptionsWrapper notifyAipUpdatedOnChanged(AIP aip) { return getModelService().notifyAipUpdatedOnChanged(aip); } + @Override + public ReturnWithExceptionsWrapper notifyAipOnHoldStatusUpdated(AIP aip, boolean status) { + return getModelService().notifyAipOnHoldStatusUpdated(aip, status); + } + @Override public ReturnWithExceptionsWrapper notifyAipDestroyed(AIP aip) { return getModelService().notifyAipDestroyed(aip); diff --git a/roda-core/roda-core/src/main/java/org/roda/core/model/ModelObservable.java b/roda-core/roda-core/src/main/java/org/roda/core/model/ModelObservable.java index 0a00279aac..bbfb2f6823 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/model/ModelObservable.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/model/ModelObservable.java @@ -44,6 +44,8 @@ public interface ModelObservable { ReturnWithExceptionsWrapper notifyAipUpdatedOnChanged(AIP aip); + ReturnWithExceptionsWrapper notifyAipOnHoldStatusUpdated(AIP aip, boolean status); + ReturnWithExceptionsWrapper notifyAipDestroyed(AIP aip); ReturnWithExceptionsWrapper notifyAipMoved(AIP aip, String oldParentId, String newParentId); diff --git a/roda-core/roda-core/src/main/java/org/roda/core/model/ModelObserver.java b/roda-core/roda-core/src/main/java/org/roda/core/model/ModelObserver.java index 817f981234..bdd62307e3 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/model/ModelObserver.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/model/ModelObserver.java @@ -10,6 +10,7 @@ import java.util.List; import org.roda.core.data.exceptions.ReturnWithExceptions; +import org.roda.core.data.v2.LiteRODAObject; import org.roda.core.data.v2.disposal.confirmation.DisposalConfirmation; import org.roda.core.data.v2.ip.AIP; import org.roda.core.data.v2.ip.DIP; @@ -38,6 +39,8 @@ public interface ModelObserver { public ReturnWithExceptions aipUpdatedOn(AIP aip); + ReturnWithExceptions aipOnHoldStatusUpdated(AIP aip, boolean status); + public ReturnWithExceptions aipDestroyed(AIP aip); public ReturnWithExceptions aipStateUpdated(AIP aip); @@ -151,4 +154,10 @@ public ReturnWithExceptions disposalConfirmationCreateOrUpd DisposalConfirmation confirmation); public ReturnWithExceptions disposalConfirmationDeleted(String confirmationId, boolean commit); + + public ReturnWithExceptions liteRODAObjectCreated(LiteRODAObject liteRODAObject); + + public ReturnWithExceptions liteRODAObjectUpdated(LiteRODAObject liteRODAObject); + + public ReturnWithExceptions liteRODAObjectDeleted(LiteRODAObject liteRODAObject); } diff --git a/roda-core/roda-core/src/main/java/org/roda/core/model/ModelService.java b/roda-core/roda-core/src/main/java/org/roda/core/model/ModelService.java index 40f7ad8ab8..02c1ae5ce0 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/model/ModelService.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/model/ModelService.java @@ -143,6 +143,8 @@ AIP destroyAIP(AIP aip, String updatedBy) AIP updateAIP(AIP aip, String updatedBy) throws GenericException, NotFoundException, RequestNotValidException, AuthorizationDeniedException; + AIP updateAIPOnHoldStatus(AIP aip, boolean status) throws AuthorizationDeniedException, GenericException; + AIP updateAIPState(AIP aip, String updatedBy) throws GenericException, NotFoundException, RequestNotValidException, AuthorizationDeniedException; @@ -164,8 +166,8 @@ Binary retrieveDescriptiveMetadataBinary(String aipId, String descriptiveMetadat Binary retrieveDescriptiveMetadataBinary(String aipId, String representationId, String descriptiveMetadataId) throws RequestNotValidException, GenericException, NotFoundException, AuthorizationDeniedException; - Binary retrieveTechnicalMetadataBinary(String aipId, String representationId, List fileDirectoryPath, String fileId) - throws RequestNotValidException, GenericException, NotFoundException, AuthorizationDeniedException ; + Binary retrieveTechnicalMetadataBinary(String aipId, String representationId, List fileDirectoryPath, + String fileId) throws RequestNotValidException, GenericException, NotFoundException, AuthorizationDeniedException; DescriptiveMetadata retrieveDescriptiveMetadata(String aipId, String descriptiveMetadataId) throws RequestNotValidException, GenericException, NotFoundException, AuthorizationDeniedException; @@ -384,7 +386,7 @@ PreservationMetadata createPreservationMetadata(PreservationMetadata.Preservatio void createTechnicalMetadata(String aipId, String representationId, String metadataType, String fileId, ContentPayload payload, String createdBy, boolean notify) throws AuthorizationDeniedException, RequestNotValidException, AlreadyExistsException, NotFoundException, GenericException; - + void updateTechnicalMetadata(String aipId, String representationId, String metadataType, String fileId, ContentPayload payload, String createdBy, boolean notify) throws AuthorizationDeniedException, RequestNotValidException, AlreadyExistsException, NotFoundException, GenericException; @@ -703,6 +705,9 @@ void createSubmission(StorageService submissionStorage, StoragePath submissionSt void createSubmission(Path submissionPath, String aipId) throws AlreadyExistsException, GenericException, RequestNotValidException, NotFoundException, AuthorizationDeniedException; + void createMetsFile(String aipId, String repId, ContentPayload contentPayload) throws RequestNotValidException, + GenericException, AlreadyExistsException, AuthorizationDeniedException, NotFoundException; + File createDocumentation(String aipId, String representationId, List directoryPath, String fileId, ContentPayload contentPayload) throws RequestNotValidException, GenericException, AlreadyExistsException, AuthorizationDeniedException, NotFoundException; @@ -817,17 +822,19 @@ void deleteDisposalSchedule(String disposalScheduleId) throws NotFoundException, DisposalConfirmation retrieveDisposalConfirmation(String disposalConfirmationId) throws RequestNotValidException, GenericException, NotFoundException, AuthorizationDeniedException; - void addDisposalHoldEntry(String disposalConfirmationId, DisposalHold disposalHold) - throws GenericException, RequestNotValidException; + void addDisposalHoldEntry(String disposalConfirmationId, DisposalHold disposalHold) throws GenericException, + RequestNotValidException, AuthorizationDeniedException, NotFoundException, AlreadyExistsException; void addDisposalHoldTransitiveEntry(String disposalConfirmationId, DisposalHold transitiveDisposalHold) - throws RequestNotValidException, GenericException; + throws RequestNotValidException, GenericException, AuthorizationDeniedException, NotFoundException, + AlreadyExistsException; void addDisposalScheduleEntry(String disposalConfirmationId, DisposalSchedule disposalSchedule) - throws RequestNotValidException, GenericException; + throws RequestNotValidException, GenericException, AuthorizationDeniedException, NotFoundException, + AlreadyExistsException; - void addAIPEntry(String disposalConfirmationId, DisposalConfirmationAIPEntry entry) - throws RequestNotValidException, GenericException; + void addAIPEntry(String disposalConfirmationId, DisposalConfirmationAIPEntry entry) throws RequestNotValidException, + GenericException, AuthorizationDeniedException, NotFoundException, AlreadyExistsException; DisposalConfirmation updateDisposalConfirmation(DisposalConfirmation disposalConfirmation) throws AuthorizationDeniedException, RequestNotValidException, NotFoundException, GenericException; @@ -973,7 +980,9 @@ int importAll(IndexService index, final FileStorageService fromStorage, final bo void exportAll(StorageService toStorage); - void importObject(IsRODAObject object, StorageService fromStorage); + void importObject(ModelService fromModel, LiteRODAObject object, boolean replaceExisting) + throws RequestNotValidException, GenericException, NotFoundException, AuthorizationDeniedException, + AlreadyExistsException; void exportObject(IsRODAObject object, StorageService toStorage, String... toPathPartials) throws RequestNotValidException, AuthorizationDeniedException, AlreadyExistsException, NotFoundException, diff --git a/roda-core/roda-core/src/main/java/org/roda/core/model/utils/ModelUtils.java b/roda-core/roda-core/src/main/java/org/roda/core/model/utils/ModelUtils.java index fe87371ec8..06652ca493 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/model/utils/ModelUtils.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/model/utils/ModelUtils.java @@ -64,7 +64,6 @@ import org.roda.core.data.v2.risks.Risk; import org.roda.core.data.v2.risks.RiskIncidence; import org.roda.core.data.v2.synchronization.central.DistributedInstance; -import org.roda.core.entity.transaction.TransactionalModelOperationLog; import org.roda.core.index.IndexService; import org.roda.core.model.lites.ParsedAIPLite; import org.roda.core.model.lites.ParsedDIPFileLite; @@ -167,6 +166,19 @@ public static StoragePath getRepresentationStoragePath(String aipId, String repr return DefaultStoragePath.parse(getRepresentationPath(aipId, representationId)); } + public static StoragePath getMetsStoragePath(String aipId, String representationId) throws RequestNotValidException { + + DefaultStoragePath metsOutputPath = null; + + if (representationId != null) { + metsOutputPath = DefaultStoragePath.parse(build(getAIPPath(aipId), + RodaConstants.STORAGE_DIRECTORY_REPRESENTATIONS, representationId, RodaConstants.STORAGE_METS_FILENAME)); + } else { + metsOutputPath = DefaultStoragePath.parse(build(getAIPPath(aipId), RodaConstants.STORAGE_METS_FILENAME)); + } + return metsOutputPath; + } + private static List getRepresentationMetadataPath(String aipId, String representationId) { return build(getRepresentationPath(aipId, representationId), RodaConstants.STORAGE_DIRECTORY_METADATA); } @@ -332,7 +344,10 @@ public static StoragePath getFileStoragePath(String aipId, String representation } if (StringUtils.isNotBlank(fileId)) { path.add(fileId); + } else { + throw new RequestNotValidException("File ID cannot be null or blank"); } + return DefaultStoragePath.parse(path); } @@ -647,7 +662,7 @@ public static StoragePath getPreservationMetadataStoragePath(String id, Preserva } public static StoragePath getTechnicalMetadataStoragePath(String aipId, String representationId, - List fileDirectoryPath, String fileId) throws RequestNotValidException { + List fileDirectoryPath, String fileId) throws RequestNotValidException { List path = build(getRepresentationPath(aipId, representationId), RodaConstants.STORAGE_DIRECTORY_METADATA, RodaConstants.STORAGE_DIRECTORY_TECHNICAL); path.addAll(fileDirectoryPath); @@ -1173,6 +1188,30 @@ public static StoragePath getDisposalConfirmationStoragePath(String disposalConf return DefaultStoragePath.parse(getDisposalConfirmationPath(disposalConfirmationId)); } + public static StoragePath getDisposalSchedulesFromDisposalConfirmationStoragePath(String disposalConfirmationId) + throws RequestNotValidException { + return DefaultStoragePath.parse(getDisposalConfirmationStoragePath(disposalConfirmationId), + RodaConstants.STORAGE_DIRECTORY_DISPOSAL_CONFIRMATION_SCHEDULES_FILENAME); + } + + public static StoragePath getDisposalTransitiveHoldsFromDisposalConfirmationStoragePath(String disposalConfirmationId) + throws RequestNotValidException { + return DefaultStoragePath.parse(getDisposalConfirmationStoragePath(disposalConfirmationId), + RodaConstants.STORAGE_DIRECTORY_DISPOSAL_CONFIRMATION_TRANSITIVE_HOLDS_FILENAME); + } + + public static StoragePath getDisposalHoldsFromDisposalConfirmationStoragePath(String disposalConfirmationId) + throws RequestNotValidException { + return DefaultStoragePath.parse(getDisposalConfirmationStoragePath(disposalConfirmationId), + RodaConstants.STORAGE_DIRECTORY_DISPOSAL_CONFIRMATION_HOLDS_FILENAME); + } + + public static StoragePath getDisposalAipsFromDisposalConfirmationStoragePath(String disposalConfirmationId) + throws RequestNotValidException { + return DefaultStoragePath.parse(getDisposalConfirmationStoragePath(disposalConfirmationId), + RodaConstants.STORAGE_DIRECTORY_DISPOSAL_CONFIRMATION_AIPS_FILENAME); + } + public static StoragePath getDisposalConfirmationAIPsPath(String disposalConfirmationId) throws RequestNotValidException { return DefaultStoragePath.parse(ModelUtils.getDisposalConfirmationStoragePath(disposalConfirmationId), diff --git a/roda-core/roda-core/src/main/java/org/roda/core/model/utils/ResourceParseUtils.java b/roda-core/roda-core/src/main/java/org/roda/core/model/utils/ResourceParseUtils.java index 55dd3a31d3..4efe7bf847 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/model/utils/ResourceParseUtils.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/model/utils/ResourceParseUtils.java @@ -200,6 +200,8 @@ private static PreservationMetadata convertResourceToPreservationMetadata(Resour type = PreservationMetadataType.FILE; id = filename.substring(0, filename.length() - RodaConstants.PREMIS_SUFFIX.length()); fileDirectoryPath = ModelUtils.extractFilePathFromRepresentationPreservationMetadata(resourcePath); + String fileIdFromURN = URNUtils.getFileIdFromURN(filename); + fileId = fileIdFromURN.substring(0, fileIdFromURN.length() - RodaConstants.PREMIS_SUFFIX.length()); } else if (filename.endsWith(RodaConstants.OTHER_TECH_METADATA_FILE_SUFFIX)) { type = PreservationMetadataType.OTHER; fileDirectoryPath = ModelUtils.extractFilePathFromRepresentationPreservationMetadata(resourcePath); @@ -475,7 +477,7 @@ private static CloseableIterabl if (isDirectoryAcceptable(classToReturn)) { filtered = iterable; } else { - filtered = CloseableIterables.filter(iterable, p -> !p.isDirectory()); + filtered = CloseableIterables.filter(iterable, p -> p != null && !p.isDirectory()); } CloseableIterable> it; diff --git a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/antivirus/ClamAntiVirus.java b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/antivirus/ClamAntiVirus.java index 2e3bef7e95..1a0161e341 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/antivirus/ClamAntiVirus.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/antivirus/ClamAntiVirus.java @@ -136,10 +136,10 @@ public VirusCheckResult checkForVirus(Path path) throws RuntimeException { LOGGER.debug("Executing virus scan in {}", path); - String clamavBin = RodaCoreFactory.getRodaConfiguration() - .getString("core.plugins.internal.virus_check.clamav.bin", "clamscan"); - String clamavParams = RodaCoreFactory.getRodaConfiguration() - .getString("core.plugins.internal.virus_check.clamav.params", "-ri"); + String clamavBin = RodaCoreFactory.getConfigurationManager() + .getConfigurationString("core.plugins.internal.virus_check.clamav.bin", "clamscan"); + String clamavParams = RodaCoreFactory.getConfigurationManager() + .getConfigurationString("core.plugins.internal.virus_check.clamav.params", "-ri"); List command = new ArrayList<>(); command.add(clamavBin); @@ -160,8 +160,8 @@ public VirusCheckResult checkForVirus(Path path) throws RuntimeException { @Override public String getVersion() { - String clamavGetVersion = RodaCoreFactory.getRodaConfiguration() - .getString("core.plugins.internal.virus_check.clamav.get_version", "clamscan --version"); + String clamavGetVersion = RodaCoreFactory.getConfigurationManager() + .getConfigurationString("core.plugins.internal.virus_check.clamav.get_version", "clamscan --version"); List command = new ArrayList<>(Arrays.asList(clamavGetVersion.split(" "))); try { String executeOutput = CommandUtility.execute(command, false); diff --git a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/characterization/SiegfriedPluginUtils.java b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/characterization/SiegfriedPluginUtils.java index 415f0e7ca5..0ef7ffc62c 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/characterization/SiegfriedPluginUtils.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/characterization/SiegfriedPluginUtils.java @@ -72,14 +72,14 @@ private SiegfriedPluginUtils() { private static List getBatchCommand(Path sourceDirectory) { List command; - String siegfriedPath = RodaCoreFactory.getRodaConfiguration().getString("core.tools.siegfried.binary", "sf"); + String siegfriedPath = RodaCoreFactory.getConfigurationManager().getConfigurationString("core.tools.siegfried.binary", "sf"); command = new ArrayList<>( Arrays.asList(siegfriedPath, "-json=true", "-z=false", sourceDirectory.toFile().getAbsolutePath())); return command; } private static String getSiegfriedServerEndpoint(Path sourceDirectory) { - String siegfriedServer = RodaCoreFactory.getRodaConfiguration().getString("core.tools.siegfried.server", + String siegfriedServer = RodaCoreFactory.getConfigurationManager().getConfigurationString("core.tools.siegfried.server", "http://localhost:5138"); return String.format("%s/identify/%s?base64=true&format=json", siegfriedServer, @@ -88,7 +88,7 @@ private static String getSiegfriedServerEndpoint(Path sourceDirectory) { public static String runSiegfriedOnPath(Path sourceDirectory) throws PluginException { try { - String siegfriedMode = RodaCoreFactory.getRodaConfiguration().getString("core.tools.siegfried.mode", "server"); + String siegfriedMode = RodaCoreFactory.getConfigurationManager().getConfigurationString("core.tools.siegfried.mode", "server"); if ("server".equalsIgnoreCase(siegfriedMode)) { LOGGER.debug("Running Siegfried on server mode"); String endpoint = getSiegfriedServerEndpoint(sourceDirectory); @@ -106,7 +106,7 @@ public static String runSiegfriedOnPath(Path sourceDirectory) throws PluginExcep public static String getVersion() { String version = null; - String siegfriedMode = RodaCoreFactory.getRodaConfiguration().getString("core.tools.siegfried.mode", "server"); + String siegfriedMode = RodaCoreFactory.getConfigurationManager().getConfigurationString("core.tools.siegfried.mode", "server"); if ("server".equalsIgnoreCase(siegfriedMode)) { LOGGER.debug("Running Siegfried on server mode"); String endpoint = getSiegfriedServerEndpoint(Paths.get("/dev/null")); diff --git a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/CreateDisposalConfirmationPlugin.java b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/CreateDisposalConfirmationPlugin.java index 5a62f23e33..6a5b7dba79 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/CreateDisposalConfirmationPlugin.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/CreateDisposalConfirmationPlugin.java @@ -12,6 +12,7 @@ import static org.roda.core.data.common.RodaConstants.PLUGIN_PARAMS_DISPOSAL_CONFIRMATION_TITLE; import static org.roda.core.data.common.RodaConstants.PreservationEventType; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -259,7 +260,8 @@ private void processAIP(ModelService model, IndexService index, Report report, J "was successfully assign to disposal confirmation", confirmationId, aip.getId()); aipCounter++; - } catch (RequestNotValidException | GenericException | NotFoundException | AuthorizationDeniedException e) { + } catch (RequestNotValidException | GenericException | NotFoundException | AuthorizationDeniedException | + AlreadyExistsException e) { LOGGER.error("Failed to assign AIP '{}' to disposal confirmation '{}': {}", aip.getId(), confirmationId, e.getMessage(), e); state = PluginState.FAILURE; @@ -294,33 +296,32 @@ private void processAIP(ModelService model, IndexService index, Report report, J model.addDisposalScheduleEntry(confirmationId, disposalSchedule); } } - } catch (RequestNotValidException | GenericException | AuthorizationDeniedException | NotFoundException e) { + } catch (RequestNotValidException | GenericException | AuthorizationDeniedException | NotFoundException + | AlreadyExistsException e) { LOGGER.error("Failed to create disposal schedules jsonl file", e); report.addPluginDetails("Failed to create jsonl with disposal schedules"); } // Make disposal holds as a jsonl try { - FSUtils.createFile(DisposalConfirmationPluginUtils.getDisposalConfirmationPath(confirmationId), - RodaConstants.STORAGE_DIRECTORY_DISPOSAL_CONFIRMATION_HOLDS_FILENAME, true, true); for (String disposalHoldId : disposalHolds) { DisposalHold disposalHold = model.retrieveDisposalHold(disposalHoldId); model.addDisposalHoldEntry(confirmationId, disposalHold); } - } catch (NotFoundException | AuthorizationDeniedException | GenericException | RequestNotValidException e) { + } catch (NotFoundException | AuthorizationDeniedException | GenericException | RequestNotValidException + | AlreadyExistsException e) { LOGGER.error("Failed to create disposal holds jsonl file", e); report.addPluginDetails("Failed to create jsonl with disposal holds"); } // Make disposal holds transitive as a jsonl try { - FSUtils.createFile(DisposalConfirmationPluginUtils.getDisposalConfirmationPath(confirmationId), - RodaConstants.STORAGE_DIRECTORY_DISPOSAL_CONFIRMATION_TRANSITIVE_HOLDS_FILENAME, true, true); for (String disposalHoldId : disposalHoldTransitives) { DisposalHold disposalHold = model.retrieveDisposalHold(disposalHoldId); model.addDisposalHoldTransitiveEntry(confirmationId, disposalHold); } - } catch (NotFoundException | AuthorizationDeniedException | GenericException | RequestNotValidException e) { + } catch (NotFoundException | AuthorizationDeniedException | GenericException | RequestNotValidException + | AlreadyExistsException e) { LOGGER.error("Failed to create transitive disposal holds jsonl file", e); report.addPluginDetails("Failed to create jsonl with transitive disposal holds"); } @@ -409,7 +410,8 @@ private void processChild(IndexedAIP child, String topAncestorId, String confirm "was skipped from being assign to disposal confirmation due to incompatible disposal schedule", confirmationId, aip.getId()); } - } catch (RequestNotValidException | GenericException | AuthorizationDeniedException | NotFoundException e) { + } catch (RequestNotValidException | GenericException | AuthorizationDeniedException | NotFoundException | + AlreadyExistsException e) { LOGGER.error("Failed to assign AIP '{}' to disposal confirmation '{}': {}", child.getId(), confirmationId, e.getMessage(), e); state = PluginState.FAILURE; diff --git a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/DeleteDisposalConfirmationPlugin.java b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/DeleteDisposalConfirmationPlugin.java index 92fc2715fc..6317f0a75c 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/DeleteDisposalConfirmationPlugin.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/DeleteDisposalConfirmationPlugin.java @@ -10,6 +10,7 @@ import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -159,9 +160,9 @@ private void processDisposalConfirmation(ModelService model, Report report, JobP Binary binary = model.getBinary(confirmation, RodaConstants.STORAGE_DIRECTORY_DISPOSAL_CONFIRMATION_AIPS_FILENAME); - try (BufferedReader reader = new BufferedReader(new InputStreamReader(binary.getContent().createInputStream()))) { - while (reader.ready()) { - String aipEntryJson = reader.readLine(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(binary.getContent().createInputStream(), StandardCharsets.UTF_8))) { + String aipEntryJson; + while ((aipEntryJson = reader.readLine()) != null) { DisposalConfirmationAIPEntry aipEntry = JsonUtils.getObjectFromJson(aipEntryJson, DisposalConfirmationAIPEntry.class); diff --git a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/DestroyRecordsPlugin.java b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/DestroyRecordsPlugin.java index 23b7a856ad..c203fff076 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/DestroyRecordsPlugin.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/DestroyRecordsPlugin.java @@ -12,13 +12,17 @@ import java.io.InputStreamReader; import java.io.Reader; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.List; +import java.util.UUID; import org.apache.commons.io.IOUtils; import org.apache.commons.io.input.ReaderInputStream; +import org.roda.core.RodaCoreFactory; import org.roda.core.common.RodaUtils; import org.roda.core.data.common.RodaConstants; import org.roda.core.data.exceptions.AlreadyExistsException; @@ -36,12 +40,16 @@ import org.roda.core.data.v2.ip.AIPState; import org.roda.core.data.v2.ip.Representation; import org.roda.core.data.v2.ip.metadata.DescriptiveMetadata; +import org.roda.core.data.v2.ip.metadata.PreservationMetadata; import org.roda.core.data.v2.jobs.Job; import org.roda.core.data.v2.jobs.PluginState; import org.roda.core.data.v2.jobs.PluginType; import org.roda.core.data.v2.jobs.Report; import org.roda.core.index.IndexService; +import org.roda.core.model.DefaultModelService; +import org.roda.core.model.LiteRODAObjectFactory; import org.roda.core.model.ModelService; +import org.roda.core.model.utils.ModelUtils; import org.roda.core.plugins.AbstractPlugin; import org.roda.core.plugins.Plugin; import org.roda.core.plugins.PluginException; @@ -49,7 +57,11 @@ import org.roda.core.plugins.RODAObjectProcessingLogic; import org.roda.core.plugins.orchestrate.JobPluginInfo; import org.roda.core.storage.Binary; +import org.roda.core.storage.DefaultStoragePath; +import org.roda.core.storage.StorageService; +import org.roda.core.storage.StorageServiceUtils; import org.roda.core.storage.StringContentPayload; +import org.roda.core.storage.fs.FileStorageService; import org.roda.core.util.CommandException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -150,12 +162,27 @@ private void processDisposalConfirmation(ModelService model, Report report, Job Binary binary = model.getBinary(disposalConfirmation, RodaConstants.STORAGE_DIRECTORY_DISPOSAL_CONFIRMATION_AIPS_FILENAME); - try (BufferedReader reader = new BufferedReader(new InputStreamReader(binary.getContent().createInputStream()))) { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(binary.getContent().createInputStream(), StandardCharsets.UTF_8))) { jobPluginInfo.setSourceObjectsCount(disposalConfirmation.getNumberOfAIPs().intValue()); + + StorageService disposalBinStorage = new FileStorageService( + RodaCoreFactory.getDisposalBinDirectoryPath().resolve(disposalConfirmation.getId()), false, null, false); + + if (disposalBinStorage.countResourcesUnderContainer(DefaultStoragePath.empty(), false) > 0) { + throw new RequestNotValidException("Disposal bin structure for disposal confirmation '" + + disposalConfirmation.getTitle() + "' (" + disposalConfirmationId + + ") already exists in storage. Please check the disposal bin and remove the existing structure before executing the plugin."); + } + + ModelService disposalBinModel = new DefaultModelService(disposalBinStorage, null, + RodaConstants.NodeType.REPLICA, UUID.randomUUID().toString()); + // Iterate over the AIP - while (reader.ready()) { - String aipEntryJson = reader.readLine(); - processAipEntry(aipEntryJson, disposalConfirmation, model, cachedJob, report, jobPluginInfo); + String aipEntryJson; + while ((aipEntryJson = reader.readLine()) != null) { + processAipEntry(aipEntryJson, disposalConfirmation, model, cachedJob, report, jobPluginInfo, + disposalBinModel); } } } catch (GenericException | NotFoundException | RequestNotValidException | AuthorizationDeniedException @@ -190,12 +217,12 @@ private void processDisposalConfirmation(ModelService model, Report report, Job } private void processAipEntry(String aipEntryJson, DisposalConfirmation disposalConfirmation, ModelService model, - Job cachedJob, Report report, JobPluginInfo jobPluginInfo) { + Job cachedJob, Report report, JobPluginInfo jobPluginInfo, ModelService disposalBinModel) { try { DisposalConfirmationAIPEntry aipEntry = JsonUtils.getObjectFromJson(aipEntryJson, DisposalConfirmationAIPEntry.class); AIP aip = model.retrieveAIP(aipEntry.getAipId()); - processAIP(aip, disposalConfirmation, model, cachedJob, report, jobPluginInfo); + processAIP(aip, disposalConfirmation, model, cachedJob, report, jobPluginInfo, disposalBinModel); } catch (GenericException | NotFoundException | RequestNotValidException | AuthorizationDeniedException e) { LOGGER.error("Failed to process AIP entry '{}': {}", aipEntryJson, e.getMessage(), e); processedWithErrors = true; @@ -211,7 +238,7 @@ private void processAipEntry(String aipEntryJson, DisposalConfirmation disposalC } private void processAIP(AIP aip, DisposalConfirmation disposalConfirmation, ModelService model, Job cachedJob, - Report report, JobPluginInfo jobPluginInfo) { + Report report, JobPluginInfo jobPluginInfo, ModelService disposalBinModel) { LOGGER.debug("Processing AIP {}", aip.getId()); @@ -229,7 +256,7 @@ private void processAIP(AIP aip, DisposalConfirmation disposalConfirmation, Mode aip.setState(AIPState.DESTROY_PROCESSING); model.updateAIPState(aip, cachedJob.getUsername()); - testAndExecuteCopyAIP2DisposalBin(aip, disposalConfirmation.getId()); + disposalBinModel.importObject(model, LiteRODAObjectFactory.get(AIP.class, aip.getId()).orElseThrow(), false); executeSetAIPMetadataInformation(aip, cachedJob.getUsername()); @@ -245,7 +272,7 @@ private void processAIP(AIP aip, DisposalConfirmation disposalConfirmation, Mode } reportItem.setPluginDetails(outcomeText); - } catch (IOException | CommandException | RequestNotValidException | GenericException | AuthorizationDeniedException + } catch (IOException | RequestNotValidException | GenericException | AuthorizationDeniedException | NotFoundException | AlreadyExistsException e) { LOGGER.error("Failed to destroy AIP '{}': {}", aip.getId(), e.getMessage(), e); state = PluginState.FAILURE; @@ -255,17 +282,16 @@ private void processAIP(AIP aip, DisposalConfirmation disposalConfirmation, Mode processedWithErrors = true; } - model.createEvent(aip.getId(), null, null, null, RodaConstants.PreservationEventType.DESTRUCTION, EVENT_DESCRIPTION, - null, null, state, outcomeText, "", cachedJob.getUsername(), true, null); + PreservationMetadata event = model.createEvent(aip.getId(), null, null, null, + RodaConstants.PreservationEventType.DESTRUCTION, EVENT_DESCRIPTION, null, null, state, outcomeText, "", + cachedJob.getUsername(), true, null); // copy the preservation event to the AIP in the disposal bin - // using the --ignore-existing flag in the rsync process, copying only the new - // preservation event, leaving the remaining AIP structure intact try { - DisposalConfirmationPluginUtils.copyAIPToDisposalBin(aip, disposalConfirmation.getId(), - Arrays.asList("-r", "--ignore-existing")); - } catch (RequestNotValidException | GenericException | CommandException e) { - LOGGER.error("Failed to copy preservation event: {}", e.getMessage(), e); + disposalBinModel.importObject(model, LiteRODAObjectFactory.get(event).orElseThrow(), false); + } catch (RequestNotValidException | GenericException | NotFoundException | AuthorizationDeniedException + | AlreadyExistsException e) { + throw new RuntimeException(e); } jobPluginInfo.incrementObjectsProcessed(state); @@ -285,16 +311,6 @@ private void executeRemoveAllRepresentations(AIP aip, ModelService model, String aip.getRepresentations().clear(); } - private void testAndExecuteCopyAIP2DisposalBin(AIP aip, String disposalConfirmationId) - throws GenericException, CommandException, RequestNotValidException { - // test if the AIP was copied to disposal bin - if (!DisposalConfirmationPluginUtils.aipExistsInDisposalBin(aip.getId(), disposalConfirmationId)) { - // Copy AIP to disposal bin - DisposalConfirmationPluginUtils.copyAIPToDisposalBin(aip, disposalConfirmationId, - Collections.singletonList("-r")); - } - } - private void executeSetAIPMetadataInformation(AIP aip, String destructionBy) { DisposalDestructionAIPMetadata destruction = aip.getDisposal().getConfirmation().getDestruction(); if (destruction == null) { diff --git a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/DisposalConfirmationPluginUtils.java b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/DisposalConfirmationPluginUtils.java index 8f2792168a..c4c2885694 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/DisposalConfirmationPluginUtils.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/DisposalConfirmationPluginUtils.java @@ -15,7 +15,6 @@ import java.util.Set; import java.util.stream.Collectors; -import org.roda.core.RodaCoreFactory; import org.roda.core.data.common.RodaConstants; import org.roda.core.data.exceptions.GenericException; import org.roda.core.data.exceptions.NotFoundException; @@ -30,14 +29,8 @@ import org.roda.core.data.v2.ip.AIP; import org.roda.core.data.v2.ip.IndexedAIP; import org.roda.core.data.v2.ip.IndexedRepresentation; -import org.roda.core.data.v2.ip.StoragePath; import org.roda.core.index.IndexService; import org.roda.core.index.utils.IterableIndexResult; -import org.roda.core.model.utils.ModelUtils; -import org.roda.core.storage.DefaultStoragePath; -import org.roda.core.storage.fs.FSUtils; -import org.roda.core.storage.rsync.RsyncUtils; -import org.roda.core.util.CommandException; /** * @author Miguel Guimarães @@ -47,38 +40,6 @@ public class DisposalConfirmationPluginUtils { private DisposalConfirmationPluginUtils() { } - public static boolean aipExistsInDisposalBin(String aipId, String disposalConfirmationId) { - // disposal-bin//aip/ - Path disposalBinPath = RodaCoreFactory.getDisposalBinDirectoryPath().resolve(disposalConfirmationId) - .resolve(RodaConstants.CORE_AIP_FOLDER).resolve(aipId); - - return FSUtils.exists(disposalBinPath); - } - - public static void copyAIPToDisposalBin(AIP aip, String disposalConfirmationId, List rsyncOptions) - throws RequestNotValidException, GenericException, CommandException { - StoragePath aipStoragePath = ModelUtils.getAIPStoragePath(aip.getId()); - Path aipPath = FSUtils.getEntityPath(RodaCoreFactory.getStoragePath(), aipStoragePath); - - // disposal-bin//aip/ - Path disposalBinPath = RodaCoreFactory.getDisposalBinDirectoryPath().resolve(disposalConfirmationId) - .resolve(RodaConstants.CORE_AIP_FOLDER).resolve(aipStoragePath.getName()); - - RsyncUtils.executeRsync(aipPath, disposalBinPath, rsyncOptions); - } - - public static void copyAIPFromDisposalBin(String aipId, String disposalConfirmationId, List rsyncOptions) - throws RequestNotValidException, GenericException, CommandException { - StoragePath aipStoragePath = ModelUtils.getAIPStoragePath(aipId); - Path aipPath = FSUtils.getEntityPath(RodaCoreFactory.getStoragePath(), aipStoragePath); - - // disposal-bin//aip/ - Path disposalBinPath = RodaCoreFactory.getDisposalBinDirectoryPath().resolve(disposalConfirmationId) - .resolve(RodaConstants.CORE_AIP_FOLDER).resolve(aipStoragePath.getName()); - - RsyncUtils.executeRsync(disposalBinPath, aipPath, rsyncOptions); - } - public static DisposalConfirmation getDisposalConfirmation(String confirmationId, String title, long storageSize, Set disposalHolds, Set disposalSchedules, long numberOfAIPs, Map extraFields) { @@ -177,10 +138,4 @@ private static void getStorageSizeInBytesForAIP(IndexService indexService, Strin entry.setAipSize(totalSize); entry.setAipNumberOfFiles(totalOfDataFiles); } - - public static Path getDisposalConfirmationPath(String disposalConfirmationId) throws RequestNotValidException { - DefaultStoragePath confirmationPath = DefaultStoragePath - .parse(ModelUtils.getDisposalConfirmationStoragePath(disposalConfirmationId)); - return FSUtils.getEntityPath(RodaCoreFactory.getStoragePath(), confirmationPath); - } } diff --git a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/PermanentlyDeleteRecordsPlugin.java b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/PermanentlyDeleteRecordsPlugin.java index 1f4513b42d..b89e090964 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/PermanentlyDeleteRecordsPlugin.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/PermanentlyDeleteRecordsPlugin.java @@ -25,6 +25,7 @@ import org.roda.core.data.v2.jobs.PluginType; import org.roda.core.data.v2.jobs.Report; import org.roda.core.index.IndexService; +import org.roda.core.model.DefaultModelService; import org.roda.core.model.ModelService; import org.roda.core.plugins.AbstractPlugin; import org.roda.core.plugins.Plugin; @@ -32,7 +33,11 @@ import org.roda.core.plugins.PluginHelper; import org.roda.core.plugins.RODAObjectProcessingLogic; import org.roda.core.plugins.orchestrate.JobPluginInfo; +import org.roda.core.storage.DefaultStoragePath; +import org.roda.core.storage.DirectResourceAccess; +import org.roda.core.storage.StorageService; import org.roda.core.storage.fs.FSUtils; +import org.roda.core.storage.fs.FileStorageService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -116,15 +121,17 @@ private void processDisposalConfirmation(ModelService model, Report report, Job PluginHelper.updatePartialJobReport(this, model, reportItem, false, cachedJob); PluginState state = PluginState.SUCCESS; try { - // disposal-bin//* - Path disposalBinPath = RodaCoreFactory.getDisposalBinDirectoryPath().resolve(confirmation.getId()); - FSUtils.deletePath(disposalBinPath); - confirmation.setState(DisposalConfirmationState.PERMANENTLY_DELETED); model.updateDisposalConfirmation(confirmation); reportItem.setPluginDetails("Records under disposal confirmation '" + confirmation.getTitle() + "' (" + confirmation.getId() + ") were deleted permanently"); + + StorageService disposalBinStorage = new FileStorageService( + RodaCoreFactory.getDisposalBinDirectoryPath().resolve(confirmation.getId()), false, null, false); + + DirectResourceAccess directAccess = disposalBinStorage.getDirectAccess(DefaultStoragePath.empty()); + FSUtils.deletePathQuietly(directAccess.getPath()); } catch (AuthorizationDeniedException | RequestNotValidException | NotFoundException | GenericException e) { LOGGER.error("Failed to permanently delete the records under disposal confirmation '{}' ({}): {}", confirmation.getTitle(), confirmation.getId(), e.getMessage(), e); diff --git a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/RestoreRecordsPlugin.java b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/RestoreRecordsPlugin.java index 3b6e80d887..6ea7518ac6 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/RestoreRecordsPlugin.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/confirmation/RestoreRecordsPlugin.java @@ -10,13 +10,16 @@ import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.Collections; import java.util.Date; import java.util.List; +import java.util.UUID; import org.roda.core.RodaCoreFactory; import org.roda.core.data.common.RodaConstants; +import org.roda.core.data.exceptions.AlreadyExistsException; import org.roda.core.data.exceptions.AuthorizationDeniedException; import org.roda.core.data.exceptions.GenericException; import org.roda.core.data.exceptions.NotFoundException; @@ -33,6 +36,9 @@ import org.roda.core.data.v2.jobs.PluginType; import org.roda.core.data.v2.jobs.Report; import org.roda.core.index.IndexService; +import org.roda.core.model.DefaultModelService; +import org.roda.core.model.DefaultTransactionalModelService; +import org.roda.core.model.LiteRODAObjectFactory; import org.roda.core.model.ModelService; import org.roda.core.plugins.AbstractPlugin; import org.roda.core.plugins.Plugin; @@ -41,7 +47,9 @@ import org.roda.core.plugins.RODAObjectProcessingLogic; import org.roda.core.plugins.orchestrate.JobPluginInfo; import org.roda.core.storage.Binary; +import org.roda.core.storage.StorageService; import org.roda.core.storage.fs.FSUtils; +import org.roda.core.storage.fs.FileStorageService; import org.roda.core.util.CommandException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -55,24 +63,24 @@ public class RestoreRecordsPlugin extends AbstractPlugin { private boolean processedWithErrors = false; + public static String getStaticName() { + return "Restore records under disposal confirmation report"; + } + + public static String getStaticDescription() { + return ""; + } + @Override public String getVersionImpl() { return "1.0"; } - public static String getStaticName() { - return "Restore records under disposal confirmation report"; - } - @Override public String getName() { return getStaticName(); } - public static String getStaticDescription() { - return ""; - } - @Override public String getDescription() { return getStaticDescription(); @@ -126,13 +134,20 @@ private void processDisposalConfirmation(IndexService index, ModelService model, Binary binary = model.getBinary(disposalConfirmation, RodaConstants.STORAGE_DIRECTORY_DISPOSAL_CONFIRMATION_AIPS_FILENAME); - try (BufferedReader reader = new BufferedReader(new InputStreamReader(binary.getContent().createInputStream()))) { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(binary.getContent().createInputStream(), StandardCharsets.UTF_8))) { jobPluginInfo.setSourceObjectsCount(disposalConfirmation.getNumberOfAIPs().intValue()); - // Iterate over the AIP - while (reader.ready()) { - String aipEntryJson = reader.readLine(); + StorageService disposalBinStorage = new FileStorageService( + RodaCoreFactory.getDisposalBinDirectoryPath().resolve(disposalConfirmation.getId()), false, null, false); - processAipEntry(aipEntryJson, disposalConfirmation, index, model, cachedJob, report, jobPluginInfo); + ModelService disposalBinModel = new DefaultModelService(disposalBinStorage, null, + RodaConstants.NodeType.REPLICA, UUID.randomUUID().toString()); + + // Iterate over the AIP + String aipEntryJson; + while ((aipEntryJson = reader.readLine()) != null) { + processAipEntry(aipEntryJson, disposalConfirmation, index, model, cachedJob, report, jobPluginInfo, + disposalBinModel); } } } catch (RequestNotValidException | AuthorizationDeniedException | GenericException | NotFoundException @@ -173,11 +188,11 @@ private void processDisposalConfirmation(IndexService index, ModelService model, } private void processAipEntry(String aipEntryJson, DisposalConfirmation disposalConfirmation, IndexService index, - ModelService model, Job cachedJob, Report report, JobPluginInfo jobPluginInfo) { + ModelService model, Job cachedJob, Report report, JobPluginInfo jobPluginInfo, ModelService disposalBinModel) { try { DisposalConfirmationAIPEntry aipEntry = JsonUtils.getObjectFromJson(aipEntryJson, DisposalConfirmationAIPEntry.class); - processAIP(aipEntry, disposalConfirmation, index, model, cachedJob, report, jobPluginInfo); + processAIP(aipEntry, disposalConfirmation, index, model, cachedJob, report, jobPluginInfo, disposalBinModel); } catch (GenericException e) { LOGGER.error("Failed to process the AIP entry '{}': {}", aipEntryJson, e.getMessage(), e); processedWithErrors = true; @@ -193,7 +208,8 @@ private void processAipEntry(String aipEntryJson, DisposalConfirmation disposalC } private void processAIP(DisposalConfirmationAIPEntry aipEntry, DisposalConfirmation disposalConfirmation, - IndexService index, ModelService model, Job cachedJob, Report report, JobPluginInfo jobPluginInfo) { + IndexService index, ModelService model, Job cachedJob, Report report, JobPluginInfo jobPluginInfo, + ModelService disposalBinModel) { LOGGER.debug("Processing AIP entry {}", aipEntry.getAipId()); @@ -205,8 +221,8 @@ private void processAIP(DisposalConfirmationAIPEntry aipEntry, DisposalConfirmat try { // Copy AIP from disposal bin to storage - DisposalConfirmationPluginUtils.copyAIPFromDisposalBin(aipEntry.getAipId(), disposalConfirmation.getId(), - Collections.singletonList("-r")); + model.importObject(disposalBinModel, LiteRODAObjectFactory.get(AIP.class, aipEntry.getAipId()).orElseThrow(), + true); // reindex the AIP AIP aip = model.retrieveAIP(aipEntry.getAipId()); @@ -223,8 +239,8 @@ private void processAIP(DisposalConfirmationAIPEntry aipEntry, DisposalConfirmat reportItem.setPluginDetails(outcomeText); - } catch (CommandException | RequestNotValidException | GenericException | NotFoundException - | AuthorizationDeniedException e) { + } catch (RequestNotValidException | GenericException | NotFoundException | AuthorizationDeniedException + | AlreadyExistsException e) { LOGGER.error("Failed to restore AIP '{}': {}", aipEntry.getAipId(), e.getMessage(), e); pluginState = PluginState.FAILURE; outcomeText = "AIP '" + aipEntry.getAipId() diff --git a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/hold/LiftDisposalHoldPlugin.java b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/hold/LiftDisposalHoldPlugin.java index c3c5c374cf..20d7d98840 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/hold/LiftDisposalHoldPlugin.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/disposal/hold/LiftDisposalHoldPlugin.java @@ -55,10 +55,6 @@ */ public class LiftDisposalHoldPlugin extends AbstractPlugin { private static final Logger LOGGER = LoggerFactory.getLogger(LiftDisposalHoldPlugin.class); - - private String disposalHoldId; - private String details; - private static final Map pluginParameters = new HashMap<>(); static { @@ -74,6 +70,17 @@ public class LiftDisposalHoldPlugin extends AbstractPlugin { .isMandatory(false).withDescription("Details that will be used when creating event").build()); } + private String disposalHoldId; + private String details; + + public static String getStaticName() { + return "Lift disposal hold"; + } + + public static String getStaticDescription() { + return ""; + } + @Override public List getParameters() { ArrayList parameters = new ArrayList<>(); @@ -100,19 +107,11 @@ public String getVersionImpl() { return "1.0"; } - public static String getStaticName() { - return "Lift disposal hold"; - } - @Override public String getName() { return getStaticName(); } - public static String getStaticDescription() { - return ""; - } - @Override public String getDescription() { return getStaticDescription(); @@ -163,7 +162,28 @@ public void process(IndexService index, ModelService model, Report report, Job c private void liftDisposalHold(IndexService index, ModelService model, Report report, Job cachedJob, JobPluginInfo jobPluginInfo) { report.addPluginDetails(details); - int count = 0; + long count = 0; + + try { + DisposalHold disposalHold = model.retrieveDisposalHold(disposalHoldId); + disposalHold.setState(DisposalHoldState.LIFTED); + disposalHold.setLiftedBy(cachedJob.getUsername()); + disposalHold.setLiftedOn(new Date()); + model.updateDisposalHold(disposalHold, cachedJob.getUsername(), details); + } catch (RequestNotValidException | NotFoundException | GenericException | AuthorizationDeniedException + | IllegalOperationException e) { + Report reportItem = PluginHelper.initPluginReportItem(this, disposalHoldId, DisposalHold.class); + PluginHelper.updatePartialJobReport(this, model, reportItem, false, cachedJob); + PluginState state = PluginState.FAILURE; + jobPluginInfo.incrementObjectsProcessedWithFailure(); + reportItem.setPluginState(state) + .setPluginDetails("Error lifting disposal hold '" + disposalHoldId + "': " + e.getMessage()); + report.addReport(reportItem); + jobPluginInfo.setSourceObjectsCount(1); + PluginHelper.updatePartialJobReport(this, model, reportItem, true, cachedJob); + + return; + } try (IterableIndexResult aipsToDelete = findAipsWithDisposalHold(index, disposalHoldId)) { for (IndexedAIP indexedAIP : aipsToDelete) { @@ -175,18 +195,12 @@ private void liftDisposalHold(IndexService index, ModelService model, Report rep try { AIP aip = model.retrieveAIP(indexedAIP.getId()); - Pair outcome = DisposalHoldPluginUtils.disassociateDisposalHoldFromAIP(disposalHoldId, aip, - reportItem); - boolean lifted = outcome.getFirst(); - outcomeText = outcome.getSecond(); - processTransitiveAIP(model, index, cachedJob, aip.getId(), disposalHoldId, jobPluginInfo, report); - model.updateAIP(aip, cachedJob.getUsername()); - if (lifted) { - jobPluginInfo.incrementObjectsProcessedWithSuccess(); - } else { - jobPluginInfo.incrementObjectsProcessedWithSkipped(); - } + long children = processTransitiveAIP(model, index, cachedJob, aip.getId(), disposalHoldId, jobPluginInfo, report); + model.updateAIPOnHoldStatus(aip, model.onDisposalHold(aip.getId())); + jobPluginInfo.incrementObjectsProcessedWithSuccess(); reportItem.setPluginState(state); + outcomeText = ""; + count += children; } catch (GenericException | NotFoundException | RequestNotValidException | AuthorizationDeniedException e) { outcomeText = "Error lifting disposal hold" + disposalHoldId + " from AIP " + indexedAIP.getId(); LOGGER.error("Error lifting disposal hold '{}' from '{}': {}", disposalHoldId, indexedAIP.getId(), @@ -210,16 +224,8 @@ private void liftDisposalHold(IndexService index, ModelService model, Report rep count++; } - jobPluginInfo.setSourceObjectsCount(count); - - DisposalHold disposalHold = model.retrieveDisposalHold(disposalHoldId); - disposalHold.setState(DisposalHoldState.LIFTED); - disposalHold.setLiftedBy(cachedJob.getUsername()); - disposalHold.setLiftedOn(new Date()); - model.updateDisposalHold(disposalHold, cachedJob.getUsername(), details); - - } catch (IOException | GenericException | RequestNotValidException | NotFoundException - | AuthorizationDeniedException | IllegalOperationException e) { + jobPluginInfo.setSourceObjectsCount((int) count); + } catch (IOException | GenericException | RequestNotValidException e) { LOGGER.error("Error getting AIPs to delete", e); } } @@ -231,7 +237,7 @@ private IterableIndexResult findAipsWithDisposalHold(IndexService in return index.findAll(IndexedAIP.class, aipsFilter, false, List.of(RodaConstants.INDEX_UUID)); } - private void processTransitiveAIP(ModelService model, IndexService index, Job cachedJob, String aipId, String holdId, + private Long processTransitiveAIP(ModelService model, IndexService index, Job cachedJob, String aipId, String holdId, JobPluginInfo jobPluginInfo, Report report) throws GenericException, NotFoundException, RequestNotValidException { IterableIndexResult results = DisposalHoldPluginUtils.getTransitivesHoldsAIPs(index, aipId); @@ -245,12 +251,10 @@ private void processTransitiveAIP(ModelService model, IndexService index, Job ca try { AIP aipChildren = model.retrieveAIP(indexedAIP.getId()); LOGGER.debug("Processing transitive AIP {}", aipId); - outcomeText = DisposalHoldPluginUtils.disassociateTransitiveDisposalHoldFromAIP(disposalHoldId, aipChildren, reportItem); - model.updateAIP(aipChildren, cachedJob.getUsername()); - reportItem.setPluginState(state) - .addPluginDetails("transitive Disposal hold '" + holdId + " was successfully lifting to AIP '" + aipId + "'"); + model.updateAIPOnHoldStatus(aipChildren, model.onDisposalHold(aipChildren.getId())); + reportItem.setPluginState(state); jobPluginInfo.incrementObjectsProcessedWithSuccess(); - + outcomeText = ""; } catch (AuthorizationDeniedException e) { state = PluginState.FAILURE; outcomeText = "Can't retrieve AIP " + aipId + " for lifting transitive hold " + holdId + "."; @@ -271,6 +275,8 @@ private void processTransitiveAIP(ModelService model, IndexService index, Job ca LOGGER.error("Error creating event: {}", e.getMessage(), e); } } + + return results.getTotalCount(); } @Override diff --git a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/ingest/v2/ConfigurableIngestPlugin.java b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/ingest/v2/ConfigurableIngestPlugin.java index db71d4cd93..572bd44193 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/ingest/v2/ConfigurableIngestPlugin.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/ingest/v2/ConfigurableIngestPlugin.java @@ -43,7 +43,7 @@ public class ConfigurableIngestPlugin extends DefaultIngestPlugin { static { // 2) virus check - steps.add(new IngestStep(AntivirusPlugin.class.getName(), RodaConstants.PLUGIN_PARAMS_DO_VIRUS_CHECK, true, false, + steps.add(new IngestStep(AntivirusPlugin.class.getName(), RodaConstants.PLUGIN_PARAMS_DO_VIRUS_CHECK, true, true, true, true)); // 3) descriptive metadata validation steps.add(new IngestStep(DescriptiveMetadataValidationPlugin.class.getName(), diff --git a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/preservation/AIPCorruptionRiskAssessmentPlugin.java b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/preservation/AIPCorruptionRiskAssessmentPlugin.java index fe5d24d96e..d57c5c37f2 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/preservation/AIPCorruptionRiskAssessmentPlugin.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/plugins/base/preservation/AIPCorruptionRiskAssessmentPlugin.java @@ -243,6 +243,14 @@ private void processAIP(IndexService index, ModelService model, Report report, J aipFailed = true; createIncidence(model, index, aip.getId(), pm.getRepresentationId(), pm.getFileDirectoryPath(), pm.getFileId(), risks.get(0)); + } catch (RequestNotValidException e) { + LOGGER.error("Error retrieving file {} of representation {}", pm.getFileId(), + pm.getRepresentationId(), e); + ValidationIssue issue = new ValidationIssue( + "File " + pm.getFileId() + " of representation " + pm.getRepresentationId() + " of AIP " + + pm.getAipId() + " could not be retrieved but the PREMIS file exists: " + e.getMessage()); + validationReport.addIssue(issue); + aipFailed = true; } } } diff --git a/roda-core/roda-core/src/main/java/org/roda/core/repository/job/JobRepository.java b/roda-core/roda-core/src/main/java/org/roda/core/repository/job/JobRepository.java new file mode 100644 index 0000000000..669a510758 --- /dev/null +++ b/roda-core/roda-core/src/main/java/org/roda/core/repository/job/JobRepository.java @@ -0,0 +1,23 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE file at the root of the source + * tree and available online at + * + * https://github.com/keeps/roda + */ +package org.roda.core.repository.job; + +import org.roda.core.data.v2.jobs.Job; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * JPA Repository for Job entities. Used to store running jobs in the database + * before they are flushed to file storage upon completion. + * + * @author RODA Development Team + */ +@Repository +public interface JobRepository extends JpaRepository { + // Standard JPA methods are inherited from JpaRepository +} diff --git a/roda-core/roda-core/src/main/java/org/roda/core/repository/job/ReportRepository.java b/roda-core/roda-core/src/main/java/org/roda/core/repository/job/ReportRepository.java new file mode 100644 index 0000000000..f30077b53f --- /dev/null +++ b/roda-core/roda-core/src/main/java/org/roda/core/repository/job/ReportRepository.java @@ -0,0 +1,43 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE file at the root of the source + * tree and available online at + * + * https://github.com/keeps/roda + */ +package org.roda.core.repository.job; + +import java.util.List; + +import org.roda.core.data.v2.jobs.Report; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +/** + * JPA Repository for Report entities. Used to store job reports in the database + * while the associated job is running, before they are flushed to file storage. + * + * @author RODA Development Team + */ +@Repository +public interface ReportRepository extends JpaRepository { + + /** + * Find all reports associated with a given job ID. + * + * @param jobId + * the job ID to search for + * @return list of reports for the specified job + */ + List findByJobId(String jobId); + + /** + * Delete all reports associated with a given job ID. + * + * @param jobId + * the job ID whose reports should be deleted + */ + @Transactional + void deleteByJobId(String jobId); +} diff --git a/roda-core/roda-core/src/main/java/org/roda/core/storage/DefaultTransactionalStorageService.java b/roda-core/roda-core/src/main/java/org/roda/core/storage/DefaultTransactionalStorageService.java index 3b4653cd0f..a389b27bc8 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/storage/DefaultTransactionalStorageService.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/storage/DefaultTransactionalStorageService.java @@ -28,6 +28,7 @@ import org.roda.core.data.exceptions.GenericException; import org.roda.core.data.exceptions.NotFoundException; import org.roda.core.data.exceptions.RequestNotValidException; +import org.roda.core.data.v2.LiteRODAObject; import org.roda.core.data.v2.ip.StoragePath; import org.roda.core.entity.transaction.OperationState; import org.roda.core.entity.transaction.OperationType; @@ -48,11 +49,10 @@ public class DefaultTransactionalStorageService implements TransactionalStorageService { private static final Logger LOGGER = LoggerFactory.getLogger(DefaultTransactionalStorageService.class); - + private final TransactionLogService transactionLogService; private StorageService stagingStorageService; private StorageService mainStorageService; private TransactionLog transaction; - private final TransactionLogService transactionLogService; private boolean isInitialized = false; public DefaultTransactionalStorageService(StorageService mainStorageService, StorageService stagingStorageService, @@ -288,7 +288,18 @@ public Binary createBinary(StoragePath storagePath, ContentPayload payload, bool throws GenericException, AlreadyExistsException, RequestNotValidException, AuthorizationDeniedException, NotFoundException { TransactionalStoragePathOperationLog operationLog; - // if storage path is agent we need to register a create or update operation + + try { + TransactionalStoragePathOperationLog anyDeletedStoragePathOperation = transactionLogService.getAnyDeletedStoragePathOperation(transaction.getId(), + storagePath.toString()); + if (anyDeletedStoragePathOperation == null && mainStorageService.exists(storagePath)) { + throw new AlreadyExistsException("Binary already exists: " + storagePath); + } + } catch (RODATransactionException e) { + throw new GenericException("[transactionId:" + transaction.getId() + + "] Failed to create binary for storage path: " + storagePath, e); + } + if (storagePath.getDirectoryPath() != null && !storagePath.getDirectoryPath().isEmpty() && storagePath.getDirectoryPath().getFirst().equals(RodaConstants.STORAGE_DIRECTORY_AGENTS)) { operationLog = registerOperation(storagePath, OperationType.CREATE_OR_UPDATE); @@ -439,6 +450,31 @@ public void copy(StorageService fromService, StoragePath fromStoragePath, Path t } + @Override + public void importObject(StorageService fromService, LiteRODAObject object, StoragePath toStoragePath, + boolean replaceExisting) throws AlreadyExistsException, GenericException, AuthorizationDeniedException, + NotFoundException, RequestNotValidException { + List operationLogs; + if (replaceExisting) { + operationLogs = registerOperationForCopy(fromService, toStoragePath, toStoragePath, + OperationType.CREATE_OR_UPDATE); + } else { + operationLogs = registerOperationForCopy(fromService, toStoragePath, toStoragePath, OperationType.CREATE); + } + try { + stagingStorageService.importObject(fromService, object, toStoragePath, replaceExisting); + for (TransactionalStoragePathOperationLog operationLog : operationLogs) { + updateOperationState(operationLog, OperationState.SUCCESS); + } + } catch (AlreadyExistsException | NotFoundException | RequestNotValidException | GenericException + | AuthorizationDeniedException e) { + for (TransactionalStoragePathOperationLog operationLog : operationLogs) { + updateOperationState(operationLog, OperationState.FAILURE); + } + throw e; + } + } + @Override public void move(StorageService fromService, StoragePath fromStoragePath, StoragePath toStoragePath) throws AlreadyExistsException, GenericException, RequestNotValidException, NotFoundException, @@ -763,7 +799,8 @@ private void handleCreateUpdateOperation(StoragePath storagePath, String version if (Container.class.isAssignableFrom(rootEntity)) { mainStorageService.createContainer(storagePath); } else if (Directory.class.isAssignableFrom(rootEntity)) { - mainStorageService.createDirectory(storagePath); + if (!mainStorageService.exists(storagePath)) + mainStorageService.createDirectory(storagePath); } else { StorageServiceUtils.syncBetweenStorageServices(stagingStorageService, storagePath, mainStorageService, storagePath, getEntity(storagePath)); diff --git a/roda-core/roda-core/src/main/java/org/roda/core/storage/InputStreamContentPayload.java b/roda-core/roda-core/src/main/java/org/roda/core/storage/InputStreamContentPayload.java index 6661aca7fc..c85f7b18a1 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/storage/InputStreamContentPayload.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/storage/InputStreamContentPayload.java @@ -15,7 +15,6 @@ import java.nio.file.StandardCopyOption; import org.roda.core.common.ProvidesInputStream; -import org.roda.core.storage.fs.FSUtils; public class InputStreamContentPayload implements ContentPayload { diff --git a/roda-core/roda-core/src/main/java/org/roda/core/storage/RangeConsumesOutputStream.java b/roda-core/roda-core/src/main/java/org/roda/core/storage/RangeConsumesOutputStream.java index 622281fe0a..83e313483c 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/storage/RangeConsumesOutputStream.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/storage/RangeConsumesOutputStream.java @@ -7,88 +7,80 @@ */ package org.roda.core.storage; -import java.io.File; import java.io.IOException; -import java.io.InputStream; import java.io.OutputStream; -import java.io.RandomAccessFile; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.Date; -import org.apache.commons.io.IOUtils; import org.roda.core.data.v2.ConsumesSkipableOutputStream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class RangeConsumesOutputStream implements ConsumesSkipableOutputStream { + private static final Logger LOGGER = LoggerFactory.getLogger(RangeConsumesOutputStream.class); + private static final String DEFAULT_MIME_TYPE = "application/octet-stream"; - private final Path directAccessPath; + private final SeekableContentPayload payload; + private final String filename; private final String mediaType; + private final Date lastModified; + private final long size; - public RangeConsumesOutputStream(Path directAccessPath, String mediaType) { - this.directAccessPath = directAccessPath; + public RangeConsumesOutputStream(SeekableContentPayload payload, String filename, Date lastModified, long size, + String mediaType) { + this.payload = payload; + this.filename = filename; + this.lastModified = lastModified; + this.size = size; this.mediaType = mediaType; } - public RangeConsumesOutputStream(Path directAccessPath) { - this(directAccessPath, DEFAULT_MIME_TYPE); + public RangeConsumesOutputStream(SeekableContentPayload payload, Binary binary) { + this(payload, binary, DEFAULT_MIME_TYPE); + } + + public RangeConsumesOutputStream(SeekableContentPayload payload, Binary binary, String mediaType) { + this.payload = payload; + this.filename = binary.getStoragePath().getName(); + this.lastModified = new Date(); // TODO missing information about binary last modified date + this.size = binary.getSizeInBytes(); + this.mediaType = mediaType; } @Override public void consumeOutputStream(OutputStream out) throws IOException { - try (InputStream in = Files.newInputStream(directAccessPath)) { - IOUtils.copyLarge(in, out); - } + payload.writeTo(out, 0, getSize()); } @Override public void consumeOutputStream(OutputStream out, int from, int len) throws IOException { - try (InputStream in = Files.newInputStream(directAccessPath)) { - IOUtils.copyLarge(in, out, from, len); - } + payload.writeTo(out, from, len); } @Override public void consumeOutputStream(OutputStream out, long from, long end) { try { - File file = directAccessPath.toFile(); - byte[] buffer = new byte[1024]; - try (RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r")) { - long pos = from; - randomAccessFile.seek(pos); - while (pos < end) { - randomAccessFile.read(buffer); - out.write(buffer); - pos += buffer.length; - } - out.flush(); - } + payload.writeTo(out, from, end - from + 1); } catch (IOException e) { - // ignore + // This error occurs when web browser cancels stream + // Which can normally happen in HTTP streaming + LOGGER.trace("Error writing to output stream", e); } } @Override public Date getLastModified() { - try { - return new Date(Files.getLastModifiedTime(directAccessPath).toMillis()); - } catch (IOException e) { - return null; - } + return lastModified; } @Override public long getSize() { - try { - return Files.size(directAccessPath); - } catch (IOException e) { - return -1; - } + return size; } @Override public String getFileName() { - return directAccessPath.getFileName().toString(); + return filename; } @Override diff --git a/roda-core/roda-core/src/main/java/org/roda/core/storage/SeekableContentPayload.java b/roda-core/roda-core/src/main/java/org/roda/core/storage/SeekableContentPayload.java new file mode 100644 index 0000000000..37bfc41623 --- /dev/null +++ b/roda-core/roda-core/src/main/java/org/roda/core/storage/SeekableContentPayload.java @@ -0,0 +1,14 @@ +package org.roda.core.storage; + +import java.io.IOException; +import java.io.OutputStream; + +public interface SeekableContentPayload extends ContentPayload { + /** + * Writes a specific range of the content to the output stream. + * * @param out The output stream to write to + * @param offset The start byte position (inclusive) + * @param length The number of bytes to write + */ + void writeTo(OutputStream out, long offset, long length) throws IOException; +} \ No newline at end of file diff --git a/roda-core/roda-core/src/main/java/org/roda/core/storage/StorageService.java b/roda-core/roda-core/src/main/java/org/roda/core/storage/StorageService.java index a8877004af..d5a0ddec1d 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/storage/StorageService.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/storage/StorageService.java @@ -18,7 +18,9 @@ import org.roda.core.data.exceptions.GenericException; import org.roda.core.data.exceptions.NotFoundException; import org.roda.core.data.exceptions.RequestNotValidException; +import org.roda.core.data.v2.LiteRODAObject; import org.roda.core.data.v2.ip.StoragePath; +import org.roda.core.transaction.RODATransactionException; /** *

@@ -355,6 +357,12 @@ void copy(StorageService fromService, StoragePath fromStoragePath, StoragePath t void copy(StorageService fromService, StoragePath fromStoragePath, Path toPath, String resource, boolean replaceExisting) throws AlreadyExistsException, GenericException, AuthorizationDeniedException; + default void importObject(StorageService fromService, LiteRODAObject object, StoragePath toStoragePath, + boolean replaceExisting) throws AlreadyExistsException, GenericException, AuthorizationDeniedException, + NotFoundException, RequestNotValidException { + throw new UnsupportedOperationException("Not supported yet."); + }; + /** * Move resources from another (or the same) storage service. * diff --git a/roda-core/roda-core/src/main/java/org/roda/core/storage/StorageServiceUtils.java b/roda-core/roda-core/src/main/java/org/roda/core/storage/StorageServiceUtils.java index dcb6ae154c..f7c9bac046 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/storage/StorageServiceUtils.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/storage/StorageServiceUtils.java @@ -24,6 +24,7 @@ import org.roda.core.data.exceptions.NotFoundException; import org.roda.core.data.exceptions.RequestNotValidException; import org.roda.core.data.v2.ip.StoragePath; +import org.roda.core.transaction.RODATransactionException; /** * Storage Service related and independent utility class @@ -113,14 +114,14 @@ public static void syncBetweenStorageServices(StorageService fromService, Storag */ public static void copyBetweenStorageServices(StorageService fromService, StoragePath fromStoragePath, StorageService toService, StoragePath toStoragePath, Class rootEntity) throws GenericException, - RequestNotValidException, NotFoundException, AlreadyExistsException, AuthorizationDeniedException { + RequestNotValidException, NotFoundException, AlreadyExistsException, AuthorizationDeniedException { copyOrMoveBetweenStorageServices(fromService, fromStoragePath, toService, toStoragePath, rootEntity, true, false); } private static void copyOrMoveBetweenStorageServices(StorageService fromService, StoragePath fromStoragePath, StorageService toService, StoragePath toStoragePath, Class rootEntity, boolean copy, boolean sync) - throws GenericException, RequestNotValidException, NotFoundException, AlreadyExistsException, - AuthorizationDeniedException { + throws GenericException, RequestNotValidException, NotFoundException, AlreadyExistsException, + AuthorizationDeniedException { if (Container.class.isAssignableFrom(rootEntity)) { toService.createContainer(toStoragePath); boolean recursive = false; diff --git a/roda-core/roda-core/src/main/java/org/roda/core/storage/StorageServiceWrapper.java b/roda-core/roda-core/src/main/java/org/roda/core/storage/StorageServiceWrapper.java index 5b329f847e..7094965c04 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/storage/StorageServiceWrapper.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/storage/StorageServiceWrapper.java @@ -20,6 +20,7 @@ import org.roda.core.data.exceptions.GenericException; import org.roda.core.data.exceptions.NotFoundException; import org.roda.core.data.exceptions.RequestNotValidException; +import org.roda.core.data.v2.LiteRODAObject; import org.roda.core.data.v2.ip.StoragePath; public class StorageServiceWrapper implements StorageService { @@ -185,6 +186,14 @@ public void copy(StorageService fromService, StoragePath fromStoragePath, Path t storageService.copy(fromService, fromStoragePath, toPath, resource, replaceExisting); } + @Override + public void importObject(StorageService fromService, LiteRODAObject object, StoragePath toStoragePath, + boolean replaceExisting) throws AlreadyExistsException, GenericException, AuthorizationDeniedException, + NotFoundException, RequestNotValidException { + RodaCoreFactory.checkIfWriteIsAllowedAndIfFalseThrowException(nodeType); + storageService.importObject(fromService, object, toStoragePath, replaceExisting); + } + @Override public void move(StorageService fromService, StoragePath fromStoragePath, StoragePath toStoragePath) throws AlreadyExistsException, GenericException, RequestNotValidException, NotFoundException, diff --git a/roda-core/roda-core/src/main/java/org/roda/core/storage/fs/FSPathContentPayload.java b/roda-core/roda-core/src/main/java/org/roda/core/storage/fs/FSPathContentPayload.java index b0da1590e7..fe4febdaf6 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/storage/fs/FSPathContentPayload.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/storage/fs/FSPathContentPayload.java @@ -9,19 +9,22 @@ import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; +import java.io.RandomAccessFile; import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; -import org.roda.core.storage.ContentPayload; +import org.apache.commons.io.IOUtils; +import org.roda.core.storage.SeekableContentPayload; /** * Class that implements {@code ContentPayload} for File System * * @author Luis Faria */ -public class FSPathContentPayload implements ContentPayload { +public class FSPathContentPayload implements SeekableContentPayload { private final Path path; @@ -44,4 +47,41 @@ public URI getURI() throws IOException, UnsupportedOperationException { return path.toUri(); } + @Override + public void writeTo(OutputStream out, long offset, long length) throws IOException { + // 1. Use NIO InputStream (Efficient: uses native pread/lseek) + try (InputStream is = Files.newInputStream(path)) { + + // 2. Seek to the start position (Instant operation on files) + long skipped = is.skip(offset); + if (skipped < offset) { + // File is smaller than the offset requested + return; + } + + // 3. Transfer only the requested amount + byte[] buffer = new byte[8192]; // Standard 8KB buffer + long remaining = length; + int bytesRead; + + // Loop while we still need data AND we haven't hit EOF + while (remaining > 0) { + // Determine how much to read: either the full buffer or the remaining bytes + int bytesToRead = (int) Math.min(buffer.length, remaining); + + bytesRead = is.read(buffer, 0, bytesToRead); + + if (bytesRead == -1) { + break; // End of file reached prematurely + } + + // Critical: Only write the bytes we actually read + out.write(buffer, 0, bytesRead); + + remaining -= bytesRead; + } + + } + } + } diff --git a/roda-core/roda-core/src/main/java/org/roda/core/storage/fs/FileStorageService.java b/roda-core/roda-core/src/main/java/org/roda/core/storage/fs/FileStorageService.java index cb85d8e6c9..47a626c2d4 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/storage/fs/FileStorageService.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/storage/fs/FileStorageService.java @@ -34,6 +34,7 @@ import org.roda.core.data.exceptions.NotFoundException; import org.roda.core.data.exceptions.RequestNotValidException; import org.roda.core.data.utils.JsonUtils; +import org.roda.core.data.v2.LiteRODAObject; import org.roda.core.data.v2.ip.ShallowFile; import org.roda.core.data.v2.ip.ShallowFiles; import org.roda.core.data.v2.ip.StoragePath; @@ -72,9 +73,8 @@ */ public class FileStorageService implements StorageService { - private static final Logger LOGGER = LoggerFactory.getLogger(FileStorageService.class); - public static final String HISTORY_SUFFIX = "-history"; + private static final Logger LOGGER = LoggerFactory.getLogger(FileStorageService.class); private static final String HISTORY_DATA_FOLDER = "data"; private static final String HISTORY_METADATA_FOLDER = "metadata"; @@ -579,6 +579,26 @@ public void copy(StorageService fromService, StoragePath fromStoragePath, Path t } } + @Override + public void importObject(StorageService fromService, LiteRODAObject object, StoragePath toStoragePath, + boolean replaceExisting) throws AlreadyExistsException, GenericException, NotFoundException, + AuthorizationDeniedException, RequestNotValidException { + StoragePath fromPath = ModelUtils.getStoragePath(object); + if (!fromService.exists(fromPath)) { + throw new NotFoundException("Source Path does not exist: " + fromPath); + } + + if (exists(toStoragePath)) { + if (replaceExisting) { + // workaround + deleteResource(toStoragePath); + } else { + throw new AlreadyExistsException("Destination already exists: " + toStoragePath); + } + } + copy(fromService, fromPath, toStoragePath); + } + @Override public void move(StorageService fromService, StoragePath fromStoragePath, StoragePath toStoragePath) throws AlreadyExistsException, GenericException, RequestNotValidException, NotFoundException, diff --git a/roda-core/roda-core/src/main/java/org/roda/core/transaction/RODATransactionManager.java b/roda-core/roda-core/src/main/java/org/roda/core/transaction/RODATransactionManager.java index a54235a049..17c7fe77f2 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/transaction/RODATransactionManager.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/transaction/RODATransactionManager.java @@ -8,15 +8,11 @@ package org.roda.core.transaction; import java.nio.file.Path; -import java.util.Arrays; import java.util.Date; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; -import java.util.stream.Stream; import org.roda.core.config.ConfigurationManager; import org.roda.core.data.common.RodaConstants; @@ -26,8 +22,6 @@ import org.roda.core.data.exceptions.RequestNotValidException; import org.roda.core.data.v2.IsRODAObject; import org.roda.core.data.v2.LiteOptionalWithCause; -import org.roda.core.data.v2.jobs.Job; -import org.roda.core.data.v2.jobs.PluginState; import org.roda.core.data.v2.jobs.Report; import org.roda.core.entity.transaction.TransactionLog; import org.roda.core.model.ModelService; @@ -94,65 +88,69 @@ public TransactionalContext beginTransaction(TransactionLog.TransactionRequestTy } public void runPluginInTransaction(Plugin plugin, List objectsToBeProcessed) - throws RODATransactionException, PluginException { + throws PluginException { String requestUUID = plugin.getParameterValues().getOrDefault(RodaConstants.PLUGIN_PARAMS_LOCK_REQUEST_UUID, IdUtils.createUUID()); plugin.getParameterValues().put(RodaConstants.PLUGIN_PARAMS_LOCK_REQUEST_UUID, requestUUID); - TransactionalContext context = beginTransaction(TransactionLog.TransactionRequestType.JOB, - UUID.fromString(requestUUID)); + TransactionalContext context; + try { + context = beginTransaction(TransactionLog.TransactionRequestType.JOB, UUID.fromString(requestUUID)); + } catch (RODATransactionException e) { + throw new PluginException("Failed to begin transaction for plugin execution", e); + } + UUID transactionId = context.transactionLog().getId(); LOGGER.debug("[transactionId:{}] Running the plugin {} in a transaction", transactionId, plugin.getName()); Date initDate = new Date(); + List reports; try { plugin.execute(context.indexService(), context.transactionalModelService(), objectsToBeProcessed); - } catch (PluginException e) { - LOGGER.error("[transactionId:{}] Plugin execution failed: {}", transactionId, e.getMessage(), e); - throw e; + reports = RODATransactionManagerUtils.getReportsForTransaction(plugin, transactionId, mainModelService); + } catch (Exception e) { + LOGGER.error("[transactionId:{}] Error during plugin execution, rolling back transaction", transactionId, e); + rollbackTransaction(transactionId); + throw new PluginException("Error during plugin execution, transaction was rolled back", e); } finally { - processPluginExecutionResult(plugin, transactionId, initDate); // remove locks if any PluginHelper.releaseObjectLock(plugin); } + + // Check if any of the reports indicate that the transaction should be rolled + // back + if (RODATransactionManagerUtils.shouldRollback(plugin, RODATransactionManagerUtils.getFailedReports(reports))) { + rollbackTransaction(transactionId); + processPluginExecutionResult(transactionId, initDate, reports, false); + } else { + // If everything is fine, commit the transaction + try { + endTransaction(transactionId); + processPluginExecutionResult(transactionId, initDate, reports, true); + } catch (RODATransactionException e) { + // If commit fails, we should attempt to rollback and log the error + rollbackTransaction(transactionId); + processPluginExecutionResult(transactionId, initDate, reports, false); + throw new PluginException("Failed to commit transaction for plugin execution, transaction was rolled back", e); + } + } } - private void processPluginExecutionResult(Plugin plugin, UUID transactionId, Date initDate) - throws RODATransactionException { + private void processPluginExecutionResult(UUID transactionId, Date initDate, List relatedReports, + boolean success) { try { - Job job = mainModelService.retrieveJob(PluginHelper.getJobId(plugin)); - List relatedReports = RODATransactionManagerUtils.getReportsForTransaction(job.getId(), transactionId, - mainModelService); - - List failedReports = relatedReports.stream() - .filter(report -> PluginState.FAILURE.equals(report.getPluginState())).toList(); - - List nonFailedReports = relatedReports.stream() - .filter(report -> !PluginState.FAILURE.equals(report.getPluginState())).toList(); - - String noRollback = plugin.getParameterValues() - .getOrDefault(RodaConstants.PLUGIN_PARAM_SKIP_ROLLBACK_ON_VALIDATION_FAILURE, ""); - Set noRollbackPlugins = Arrays.stream(noRollback.split(",")).map(String::trim).filter(s -> !s.isEmpty()) - .collect(Collectors.toSet()); - - boolean shouldRollback = failedReports.stream().flatMap(fr -> { - List nested = fr.getReports(); - return nested == null ? Stream.empty() : nested.stream(); - }).filter(nr -> PluginState.FAILURE.equals(nr.getPluginState())).map(Report::getPlugin) - .filter(java.util.Objects::nonNull).anyMatch(pluginName -> !noRollbackPlugins.contains(pluginName)); + if (success) { + RODATransactionManagerUtils.createTransactionSuccessReports(relatedReports, transactionId, initDate, + mainModelService); + } else { + List failedReports = RODATransactionManagerUtils.getFailedReports(relatedReports); + List nonFailedReports = RODATransactionManagerUtils.getNonFailedReports(relatedReports); - if (shouldRollback) { - rollbackTransaction(transactionId); RODATransactionManagerUtils.createTransactionFailureReports(failedReports, nonFailedReports, transactionId, initDate, mainModelService); - } else { - endTransaction(transactionId); - RODATransactionManagerUtils.createTransactionSuccessReports(relatedReports, transactionId, initDate, - mainModelService); } - } catch (RequestNotValidException | GenericException | NotFoundException | AuthorizationDeniedException e) { - throw new RODATransactionException( - "Error handling plugin result for plugin: " + plugin.getName() + " with transaction ID: " + transactionId, e); + } catch (GenericException | RequestNotValidException | AuthorizationDeniedException | NotFoundException e) { + LOGGER.error("Critical: Failed to generate reports for transaction {}", transactionId, e); } } @@ -176,29 +174,32 @@ public void endTransaction(UUID transactionID) throws RODATransactionException { transactionLogService.changeStatus(transactionID, TransactionLog.TransactionStatus.COMMITTED); } - public void rollbackTransaction(UUID transactionID) throws RODATransactionException { + public void rollbackTransaction(UUID transactionID) { TransactionalContext context = transactionsContext.get(transactionID); if (context == null) { - throw new RODATransactionException("No transaction context found for ID: " + transactionID); - } - - transactionLogService.changeStatus(transactionID, TransactionLog.TransactionStatus.ROLLING_BACK); - - try { - if (context.transactionalStorageService() != null) { - context.transactionalStorageService().rollback(); - } - - if (context.transactionalModelService() != null) { - context.transactionalModelService().rollback(); + LOGGER.error("No transaction context found for ID: {}", transactionID); + } else { + try { + transactionLogService.changeStatus(transactionID, TransactionLog.TransactionStatus.ROLLING_BACK); + if (context.transactionalStorageService() != null) { + context.transactionalStorageService().rollback(); + } + + if (context.transactionalModelService() != null) { + context.transactionalModelService().rollback(); + } + + transactionsContext.remove(transactionID); + transactionLogService.changeStatus(transactionID, TransactionLog.TransactionStatus.ROLLED_BACK); + } catch (Exception e) { + LOGGER.error("Error during rollback of transaction: {}", transactionID, e); + try { + transactionLogService.changeStatus(transactionID, TransactionLog.TransactionStatus.ROLL_BACK_FAILED); + } catch (RODATransactionException ex) { + LOGGER.error("Error updating transaction log status to ROLL_BACK_FAILED for transaction: {}", transactionID, + ex); + } } - - transactionsContext.remove(transactionID); - transactionLogService.changeStatus(transactionID, TransactionLog.TransactionStatus.ROLLED_BACK); - - } catch (Exception e) { - transactionLogService.changeStatus(transactionID, TransactionLog.TransactionStatus.ROLL_BACK_FAILED); - throw new RODATransactionException("Error during rollback of transaction: " + transactionID, e); } } diff --git a/roda-core/roda-core/src/main/java/org/roda/core/transaction/RODATransactionManagerUtils.java b/roda-core/roda-core/src/main/java/org/roda/core/transaction/RODATransactionManagerUtils.java index 4d37dc45ae..8c5fa73015 100644 --- a/roda-core/roda-core/src/main/java/org/roda/core/transaction/RODATransactionManagerUtils.java +++ b/roda-core/roda-core/src/main/java/org/roda/core/transaction/RODATransactionManagerUtils.java @@ -9,20 +9,28 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Date; import java.util.List; +import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.roda.core.common.iterables.CloseableIterable; +import org.roda.core.data.common.RodaConstants; import org.roda.core.data.exceptions.AuthorizationDeniedException; import org.roda.core.data.exceptions.GenericException; import org.roda.core.data.exceptions.NotFoundException; import org.roda.core.data.exceptions.RequestNotValidException; +import org.roda.core.data.v2.IsRODAObject; import org.roda.core.data.v2.common.OptionalWithCause; import org.roda.core.data.v2.jobs.Job; import org.roda.core.data.v2.jobs.PluginState; import org.roda.core.data.v2.jobs.Report; import org.roda.core.model.ModelService; +import org.roda.core.plugins.Plugin; +import org.roda.core.plugins.PluginHelper; import org.roda.core.util.IdUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,27 +41,31 @@ public class RODATransactionManagerUtils { private static final Logger LOGGER = LoggerFactory.getLogger(RODATransactionManagerUtils.class); - public static List getReportsForTransaction(String jobId, UUID transactionId, ModelService model) - throws AuthorizationDeniedException, RequestNotValidException, NotFoundException, GenericException, - RODATransactionException { - List reports = new ArrayList<>(); - try (CloseableIterable> reportList = model.listJobReports(jobId)) { - for (OptionalWithCause optionalReport : reportList) { - if (optionalReport.isPresent()) { - Report innerReport = optionalReport.get(); - if (innerReport.getTransactionId().equals(transactionId.toString())) { - reports.add(innerReport); + public static List getReportsForTransaction(Plugin plugin, UUID transactionId, + ModelService model) throws RODATransactionException { + try { + Job job = model.retrieveJob(PluginHelper.getJobId(plugin)); + List reports = new ArrayList<>(); + try (CloseableIterable> reportList = model.listJobReports(job.getId())) { + for (OptionalWithCause optionalReport : reportList) { + if (optionalReport.isPresent()) { + Report innerReport = optionalReport.get(); + if (innerReport.getTransactionId().equals(transactionId.toString())) { + reports.add(innerReport); + } } } } - } catch (NotFoundException | IOException e) { + return reports; + } catch (NotFoundException | IOException | RequestNotValidException | GenericException + | AuthorizationDeniedException e) { throw new RODATransactionException("Error retrieving reports for transaction ID: " + transactionId, e); } - return reports; } public static void createTransactionFailureReports(List failedReports, List nonFailedReports, - UUID transactionId, Date initDate, ModelService model) throws RODATransactionException { + UUID transactionId, Date initDate, ModelService model) + throws AuthorizationDeniedException, RequestNotValidException, NotFoundException, GenericException { for (Report report : nonFailedReports) { String details = "This transaction failed because a related transaction also failed"; @@ -67,7 +79,8 @@ public static void createTransactionFailureReports(List failedReports, L } public static void createTransactionSuccessReports(List relatedReports, UUID transactionId, Date initDate, - ModelService model) throws RODATransactionException { + ModelService model) + throws AuthorizationDeniedException, RequestNotValidException, NotFoundException, GenericException { String details = "Transaction was committed successfully."; for (Report report : relatedReports) { @@ -76,31 +89,49 @@ public static void createTransactionSuccessReports(List relatedReports, } public static void createTransactionReportItem(Report innerReport, UUID transactionId, PluginState state, - Date initDate, String details, ModelService model) throws RODATransactionException { - try { - Job job = model.retrieveJob(innerReport.getJobId()); - innerReport.setTotalSteps(innerReport.getTotalSteps() + 1); - - Report reportItem = new Report(); - reportItem.injectLineSeparator(System.lineSeparator()); - reportItem.setId(IdUtils.getJobReportId(innerReport.getJobId(), innerReport.getSourceObjectId(), - innerReport.getOutcomeObjectId())); - reportItem.setJobId(innerReport.getJobId()); - reportItem.setSourceAndOutcomeObjectId(innerReport.getSourceObjectId(), innerReport.getOutcomeObjectId()); - reportItem.setTitle("RODA Transaction Manager"); - reportItem.setPlugin(RODATransactionManager.class.getName()); - reportItem.setPluginName("RODA Transaction Manager"); - reportItem.setPluginDetails(String.format("[Transaction ID: %s] %s", transactionId, details)); - reportItem.setPluginState(state); - reportItem.setOutcomeObjectState(innerReport.getOutcomeObjectState()); - reportItem.setDateCreated(initDate); - reportItem.setDateUpdated(new Date()); - reportItem.setHtmlPluginDetails(innerReport.isHtmlPluginDetails()); - innerReport.addReport(reportItem); - - model.createOrUpdateJobReport(innerReport, job); - } catch (RequestNotValidException | GenericException | NotFoundException | AuthorizationDeniedException e) { - throw new RODATransactionException("Error adding report item for transaction ID: " + transactionId, e); - } + Date initDate, String details, ModelService model) + throws AuthorizationDeniedException, RequestNotValidException, NotFoundException, GenericException { + + Job job = model.retrieveJob(innerReport.getJobId()); + innerReport.setTotalSteps(innerReport.getTotalSteps() + 1); + + Report reportItem = new Report(); + reportItem.injectLineSeparator(System.lineSeparator()); + reportItem.setId(IdUtils.getJobReportId(innerReport.getJobId(), innerReport.getSourceObjectId(), + innerReport.getOutcomeObjectId())); + reportItem.setJobId(innerReport.getJobId()); + reportItem.setSourceAndOutcomeObjectId(innerReport.getSourceObjectId(), innerReport.getOutcomeObjectId()); + reportItem.setTitle("RODA Transaction Manager"); + reportItem.setPlugin(RODATransactionManager.class.getName()); + reportItem.setPluginName("RODA Transaction Manager"); + reportItem.setPluginDetails(String.format("[Transaction ID: %s] %s", transactionId, details)); + reportItem.setPluginState(state); + reportItem.setOutcomeObjectState(innerReport.getOutcomeObjectState()); + reportItem.setDateCreated(initDate); + reportItem.setDateUpdated(new Date()); + reportItem.setHtmlPluginDetails(innerReport.isHtmlPluginDetails()); + innerReport.addReport(reportItem); + + model.createOrUpdateJobReport(innerReport, job); + } + + public static boolean shouldRollback(Plugin plugin, List failedReports) { + String noRollback = plugin.getParameterValues() + .getOrDefault(RodaConstants.PLUGIN_PARAM_SKIP_ROLLBACK_ON_VALIDATION_FAILURE, ""); + + Set noRollbackPlugins = Arrays.stream(noRollback.split(",")).map(String::trim).filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + + return failedReports.stream().flatMap(fr -> fr.getReports() == null ? Stream.empty() : fr.getReports().stream()) + .filter(nr -> PluginState.FAILURE.equals(nr.getPluginState())).map(Report::getPlugin) + .filter(java.util.Objects::nonNull).anyMatch(pluginName -> !noRollbackPlugins.contains(pluginName)); + } + + public static List getFailedReports(List reports) { + return reports.stream().filter(report -> PluginState.FAILURE.equals(report.getPluginState())).toList(); + } + + public static List getNonFailedReports(List reports) { + return reports.stream().filter(report -> !PluginState.FAILURE.equals(report.getPluginState())).toList(); } } diff --git a/roda-core/roda-core/src/main/resources/config/roda-core.properties b/roda-core/roda-core/src/main/resources/config/roda-core.properties index e92b932660..753e0b7584 100644 --- a/roda-core/roda-core/src/main/resources/config/roda-core.properties +++ b/roda-core/roda-core/src/main/resources/config/roda-core.properties @@ -81,9 +81,10 @@ core.storage.type=FILESYSTEM ########################################################################## core.solr.type=CLOUD core.solr.cloud.urls=localhost:2181 -core.solr.cloud.connect.timeout_ms=60000 +core.solr.cloud.connect.timeout_ms=300000 core.solr.cloud.healthcheck.retries=100 core.solr.cloud.healthcheck.timeout_ms=10000 +core.solr.cloud.zk.client.timeout_ms=600000 # Stemming and stopwords configuration for "*_txt" fields # When missing or blank Solr uses the "text_general" type for "*_txt" diff --git a/roda-core/roda-core/src/main/resources/config/roda-roles.properties b/roda-core/roda-core/src/main/resources/config/roda-roles.properties index 278efa89f8..40ee50fc25 100644 --- a/roda-core/roda-core/src/main/resources/config/roda-roles.properties +++ b/roda-core/roda-core/src/main/resources/config/roda-roles.properties @@ -76,7 +76,7 @@ core.roles.org.roda.wui.api.v2.controller.TransferredResourceController.download core.roles.org.roda.wui.api.v2.controller.TransferredResourceController.getSelectedTransferredResources = transfer.read core.roles.org.roda.wui.api.v2.controller.TransferredResourceController.createTransferredResource = transfer.create core.roles.org.roda.wui.api.v2.controller.TransferredResourceController.reindexResources = transfer.create -core.roles.org.roda.wui.api.v2.controller.TransferredResourceController.renameTransferredResource = transfer.create +core.roles.org.roda.wui.api.v2.controller.TransferredResourceController.renameTransferredResource = transfer.update core.roles.org.roda.wui.api.v2.controller.TransferredResourceController.refreshTransferResource = transfer.read core.roles.org.roda.wui.api.v2.controller.TransferredResourceController.moveTransferredResources = transfer.create diff --git a/roda-ui/roda-wui/pom.xml b/roda-ui/roda-wui/pom.xml index 6d2bb0b257..344f96445c 100644 --- a/roda-ui/roda-wui/pom.xml +++ b/roda-ui/roda-wui/pom.xml @@ -77,7 +77,7 @@ true - org.project + org.gwtproject gwt-dev @@ -124,8 +124,6 @@ -Droda.home=${env.HOME}/.roda_local -Droda.environment.collect.version=false -Dgwt.codeServerPort=9876 -Xdebug - - -Dorg.springframework.boot.logging.LoggingSystem=none -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5007 --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.lang=ALL-UNNAMED @@ -182,8 +180,6 @@ -Droda.home=${env.HOME}/.roda_central -Droda.environment.collect.version=false -Dgwt.codeServerPort=9876 -Xdebug - - -Dorg.springframework.boot.logging.LoggingSystem=none -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5006 --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.lang=ALL-UNNAMED @@ -238,9 +234,6 @@ -Dgwt.codeServerPort=9876 -Xdebug - - -Dorg.springframework.boot.logging.LoggingSystem=none - -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5005 -Droda.environment.collect.version=false --add-opens java.base/java.util=ALL-UNNAMED @@ -351,7 +344,7 @@ gwt-servlet-jakarta - com.ekotrope + org.roda-community gwt-completablefuture 1.0.1 @@ -470,7 +463,7 @@ jakarta.ws.rs-api - org.fusesource.restygwt + org.roda-community restygwt @@ -486,7 +479,7 @@ org.springdoc springdoc-openapi-starter-webmvc-ui - 2.8.14 + 2.8.16 org.webjars diff --git a/roda-ui/roda-wui/src/main/java/org/roda/wui/RODA.java b/roda-ui/roda-wui/src/main/java/org/roda/wui/RODA.java index 43f2d6f28b..055ddbdfe8 100644 --- a/roda-ui/roda-wui/src/main/java/org/roda/wui/RODA.java +++ b/roda-ui/roda-wui/src/main/java/org/roda/wui/RODA.java @@ -23,7 +23,7 @@ UserDetailsServiceAutoConfiguration.class}) @ComponentScan(basePackages = {"org.roda.*"}) @EnableJpaRepositories(basePackages = "org.roda.core.repository") -@EntityScan(basePackages = "org.roda.core.entity") +@EntityScan(basePackages = {"org.roda.core.entity", "org.roda.core.data.v2.jobs"}) @ServletComponentScan @EnableScheduling public class RODA { diff --git a/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/controller/DisposalConfirmationController.java b/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/controller/DisposalConfirmationController.java index ba45f0526c..bc7fb4cdfc 100644 --- a/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/controller/DisposalConfirmationController.java +++ b/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/controller/DisposalConfirmationController.java @@ -115,7 +115,7 @@ public ResponseEntity process(RequestContext requestConte // delegate and return return ApiUtils.okResponse( - disposalConfirmationService.createDisposalConfirmationReport(disposalConfirmationId, toPrint), null); + disposalConfirmationService.createDisposalConfirmationReport(requestContext.getModelService(), disposalConfirmationId, toPrint), null); } }); } diff --git a/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/controller/FilesController.java b/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/controller/FilesController.java index bc36fc55c5..ba82e427df 100644 --- a/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/controller/FilesController.java +++ b/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/controller/FilesController.java @@ -102,21 +102,22 @@ public class FilesController implements FileRestService, Exportable { @ApiResponse(responseCode = "404", description = "Not found", content = @Content(schema = @Schema(implementation = ErrorResponseMessage.class)))}) public ResponseEntity previewBinary( @Parameter(description = "The UUID of the existing file", required = true) @PathVariable(name = "uuid") String fileUUID, + @Parameter(description = "Use to set the content disposition inline") @RequestParam(name = "inline", defaultValue = "true", required = false) boolean inline, @RequestHeader HttpHeaders headers) { return requestHandler.processRequest(new RequestHandler.RequestProcessor>() { @Override public ResponseEntity process(RequestContext requestContext, RequestControllerAssistant controllerAssistant) throws RODAException, RESTException { - controllerAssistant.setRelatedObjectId(fileUUID); controllerAssistant.setParameters(RodaConstants.CONTROLLER_FILE_UUID_PARAM, fileUUID); List fileFields = new ArrayList<>(RodaConstants.FILE_FIELDS_TO_RETURN); fileFields.add(RodaConstants.FILE_ISDIRECTORY); IndexedFile file = indexService.retrieve(IndexedFile.class, fileUUID, fileFields); + controllerAssistant.setRelatedObjectId(file.getAipId()); controllerAssistant.checkObjectPermissions(requestContext.getUser(), file); RangeConsumesOutputStream stream = filesService.retrieveAIPRepresentationRangeStream(requestContext, file); - return ApiUtils.rangeResponse(headers, stream); + return ApiUtils.rangeResponse(headers, stream, inline); } }); } @@ -136,7 +137,8 @@ public ResponseEntity process(RequestContext requestConte fileFields.add(RodaConstants.FILE_ISDIRECTORY); IndexedFile file = indexService.retrieve(IndexedFile.class, fileUUID, fileFields); controllerAssistant.setRelatedObjectId(file.getAipId()); - controllerAssistant.setParameters(RodaConstants.CONTROLLER_FILE_UUID_PARAM, fileUUID, RodaConstants.CONTROLLER_FILE_ID_PARAM, file.getId()); + controllerAssistant.setParameters(RodaConstants.CONTROLLER_FILE_UUID_PARAM, fileUUID, + RodaConstants.CONTROLLER_FILE_ID_PARAM, file.getId()); controllerAssistant.checkObjectPermissions(requestContext.getUser(), file); diff --git a/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/controller/MembersController.java b/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/controller/MembersController.java index 8a71b7de60..7b20c7f465 100644 --- a/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/controller/MembersController.java +++ b/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/controller/MembersController.java @@ -335,7 +335,7 @@ public Void deleteAccessKey(String accessKeyId) { state = LogEntryState.FAILURE; throw new RESTException(e); } finally { - controllerAssistant.registerAction(requestContext, state, RodaConstants.CONTROLLER_ACCESS_KEY_PARAM); + controllerAssistant.registerAction(requestContext, state); } } @@ -360,7 +360,8 @@ public AccessKeys getAccessKeysByUser(String username) { state = LogEntryState.FAILURE; throw new RESTException(e); } finally { - controllerAssistant.registerAction(requestContext, state, RodaConstants.CONTROLLER_ACCESS_KEY_PARAM); + controllerAssistant.registerAction(requestContext, state, RodaConstants.CONTROLLER_ACCESS_KEY_USERNAME_PARAM, + username); } } @@ -380,7 +381,8 @@ public AccessKey getAccessKey(String accessKeyId) { state = LogEntryState.FAILURE; throw new RESTException(e); } finally { - controllerAssistant.registerAction(requestContext, state, RodaConstants.CONTROLLER_ACCESS_KEY_PARAM); + controllerAssistant.registerAction(requestContext, state, RodaConstants.CONTROLLER_ACCESS_KEY_ID_PARAM, + accessKeyId); } } @@ -414,7 +416,7 @@ public AccessToken authenticate(@RequestBody String token) { state = LogEntryState.FAILURE; throw new RESTException(e); } finally { - controllerAssistant.registerAction(requestContext, state, RodaConstants.CONTROLLER_ACCESS_KEY_PARAM); + controllerAssistant.registerAction(requestContext, state); } } @@ -437,8 +439,8 @@ public AccessKey regenerateAccessKey(String id, @RequestBody CreateAccessKeyRequ state = LogEntryState.FAILURE; throw new RESTException(e); } finally { - controllerAssistant.registerAction(requestContext, state, RodaConstants.CONTROLLER_ACCESS_KEY_PARAM, - regenerateAccessKeyRequest); + controllerAssistant.registerAction(requestContext, state, RodaConstants.CONTROLLER_ACCESS_KEY_ID_PARAM, id, + RodaConstants.CONTROLLER_ACCESS_KEY_EXP_DATE_PARAM, regenerateAccessKeyRequest.getExpirationDate()); } } @@ -465,8 +467,9 @@ public AccessKey createAccessKey(String id, @RequestBody CreateAccessKeyRequest state = LogEntryState.FAILURE; throw new RESTException(e); } finally { - controllerAssistant.registerAction(requestContext, state, RodaConstants.CONTROLLER_ACCESS_KEY_PARAM, - accessKeyRequest); + controllerAssistant.registerAction(requestContext, state, RodaConstants.CONTROLLER_ACCESS_KEY_NAME_PARAM, + accessKeyRequest.getName(), RodaConstants.CONTROLLER_ACCESS_KEY_EXP_DATE_PARAM, + accessKeyRequest.getExpirationDate()); } } @@ -488,7 +491,8 @@ public AccessKey revokeAccessKey(String accessKeyId) { state = LogEntryState.FAILURE; throw new RESTException(e); } finally { - controllerAssistant.registerAction(requestContext, state, RodaConstants.CONTROLLER_ACCESS_KEY_PARAM); + controllerAssistant.registerAction(requestContext, state, RodaConstants.CONTROLLER_ACCESS_KEY_ID_PARAM, + accessKeyId); } } diff --git a/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/controller/RequestHandler.java b/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/controller/RequestHandler.java index df165d79bf..059e4e1081 100644 --- a/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/controller/RequestHandler.java +++ b/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/controller/RequestHandler.java @@ -9,14 +9,12 @@ import java.io.IOException; -import io.micrometer.core.annotation.Timed; import org.roda.core.RodaCoreFactory; import org.roda.core.data.common.RodaConstants; import org.roda.core.data.exceptions.AuthorizationDeniedException; import org.roda.core.data.exceptions.RODAException; import org.roda.core.data.v2.log.LogEntryState; import org.roda.core.entity.transaction.TransactionLog; -import org.roda.core.transaction.RODATransactionException; import org.roda.core.transaction.RODATransactionManager; import org.roda.core.transaction.TransactionalContext; import org.roda.wui.api.v2.exceptions.RESTException; @@ -28,6 +26,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; +import io.micrometer.core.annotation.Timed; import jakarta.servlet.http.HttpServletRequest; /** @@ -108,19 +107,15 @@ private T processRequest(RequestProcessor processor, Class returnClass controllerAssistant.registerAction(requestContext, controllerAssistant.getRelatedObjectId(), state, controllerAssistant.getParameters()); } - try { - if (isAValidTransactionalContext(isTransactional) && transactionalContext != null - && state != LogEntryState.SUCCESS) { - transactionManager.rollbackTransaction(transactionalContext.transactionLog().getId()); - } - } catch (RODATransactionException ex) { - LOGGER.error("Error rolling back transaction", ex); + if (isAValidTransactionalContext(isTransactional) && transactionalContext != null + && state != LogEntryState.SUCCESS) { + transactionManager.rollbackTransaction(transactionalContext.transactionLog().getId()); } } } private boolean isAValidTransactionalContext(boolean isTransactional) { - if(transactionManager != null && transactionManager.isInitialized()) { + if (transactionManager != null && transactionManager.isInitialized()) { // Check if the current node is not a read-only node boolean writeIsAllowed = RodaCoreFactory.checkIfWriteIsAllowed(RodaCoreFactory.getNodeType()); return writeIsAllowed && isTransactional; @@ -132,4 +127,4 @@ public interface RequestProcessor { T process(RequestContext requestContext, RequestControllerAssistant controllerAssistant) throws RODAException, RESTException, IOException; } -} \ No newline at end of file +} diff --git a/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/services/DIPFileService.java b/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/services/DIPFileService.java index 682b102928..2deee1890a 100644 --- a/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/services/DIPFileService.java +++ b/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/services/DIPFileService.java @@ -7,6 +7,10 @@ */ package org.roda.wui.api.v2.services; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + import org.roda.core.data.exceptions.AuthorizationDeniedException; import org.roda.core.data.exceptions.GenericException; import org.roda.core.data.exceptions.NotFoundException; @@ -17,15 +21,14 @@ import org.roda.core.data.v2.ip.DIPFile; import org.roda.core.model.LiteRODAObjectFactory; import org.roda.core.model.ModelService; +import org.roda.core.storage.Binary; +import org.roda.core.storage.ContentPayload; import org.roda.core.storage.DirectResourceAccess; import org.roda.core.storage.RangeConsumesOutputStream; +import org.roda.core.storage.SeekableContentPayload; import org.roda.wui.common.model.RequestContext; import org.springframework.stereotype.Service; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - /** * * @author Eduardo Teixeira @@ -33,13 +36,21 @@ @Service public class DIPFileService { public RangeConsumesOutputStream retrieveDIPFileRangeStream(RequestContext requestContext, DIPFile dipfile) - throws RequestNotValidException { + throws RequestNotValidException { ModelService model = requestContext.getModelService(); if (!dipfile.isDirectory()) { final RangeConsumesOutputStream stream; try { - DirectResourceAccess directDIPFileAccess = model.getDirectAccess(dipfile); - stream = new RangeConsumesOutputStream(directDIPFileAccess.getPath()); + Binary binary = model.getBinary(dipfile); + ContentPayload payload = binary.getContent(); + + if (payload instanceof SeekableContentPayload) { + SeekableContentPayload seekable = (SeekableContentPayload) payload; + + stream = new RangeConsumesOutputStream(seekable, binary); + } else { + throw new RequestNotValidException("Range stream for file unsupported"); + } return stream; } catch (RequestNotValidException | GenericException | AuthorizationDeniedException | NotFoundException e) { throw new RuntimeException(e); @@ -50,7 +61,7 @@ public RangeConsumesOutputStream retrieveDIPFileRangeStream(RequestContext reque } public StreamResponse retrieveDIPFileStreamResponse(RequestContext requestContext, DIPFile dipFile) - throws GenericException, RequestNotValidException, NotFoundException, AuthorizationDeniedException { + throws GenericException, RequestNotValidException, NotFoundException, AuthorizationDeniedException { ModelService model = requestContext.getModelService(); final ConsumesOutputStream stream; diff --git a/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/services/DisposalConfirmationService.java b/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/services/DisposalConfirmationService.java index c0e07b01cd..3a5a3fafc8 100644 --- a/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/services/DisposalConfirmationService.java +++ b/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/services/DisposalConfirmationService.java @@ -7,6 +7,7 @@ */ package org.roda.wui.api.v2.services; +import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.PrintStream; @@ -41,8 +42,10 @@ import org.roda.core.data.v2.disposal.confirmation.DisposalConfirmationForm; import org.roda.core.data.v2.generics.MetadataValue; import org.roda.core.data.v2.index.select.SelectedItems; +import org.roda.core.data.v2.ip.StoragePath; import org.roda.core.data.v2.jobs.Job; import org.roda.core.data.v2.user.User; +import org.roda.core.model.ModelService; import org.roda.core.model.utils.ModelUtils; import org.roda.core.plugins.base.disposal.confirmation.CreateDisposalConfirmationPlugin; import org.roda.core.plugins.base.disposal.confirmation.DeleteDisposalConfirmationPlugin; @@ -50,6 +53,7 @@ import org.roda.core.plugins.base.disposal.confirmation.PermanentlyDeleteRecordsPlugin; import org.roda.core.plugins.base.disposal.confirmation.RestoreRecordsPlugin; import org.roda.core.storage.DefaultStoragePath; +import org.roda.core.storage.DirectResourceAccess; import org.roda.core.storage.fs.FSUtils; import org.roda.core.util.CommandException; import org.roda.core.util.CommandUtility; @@ -153,99 +157,79 @@ private Map getDisposalConfirmationExtra(DisposalConfirmationFor return data; } - public StreamResponse createDisposalConfirmationReport(String confirmationId, boolean isToPrint) - throws RODAException, IOException { - String jqCommandTemplate = RodaCoreFactory.getRodaConfigurationAsString(DISPOSAL_CONFIRMATION_COMMAND_PROPERTY); - - Path metadataPath = getDisposalConfirmationMetadataPath(confirmationId); - Path aipsPath = getDisposalConfirmationAIPsPath(confirmationId); - Path schedulesPath = getDisposalConfirmationSchedulesPath(confirmationId); - Path holdsPath = getDisposalConfirmationHoldsPath(confirmationId); - - Map values = new HashMap<>(); - - values.put(METADATA_FILE_PLACEHOLDER, metadataPath.toString()); - values.put(AIPS_FILE_PLACEHOLDER, aipsPath.toString()); - values.put(SCHEDULES_FILE_PLACEHOLDER, schedulesPath.toString()); - values.put(HOLDS_FILE_PLACEHOLDER, holdsPath.toString()); - - String jqCommandParams = HandlebarsUtility.executeHandlebars(jqCommandTemplate, values); - - List jqCommand = new ArrayList<>(); - Collections.addAll(jqCommand, jqCommandParams.split(" ")); - - String output; - try { - output = CommandUtility.execute(jqCommand); - } catch (CommandException e) { - throw new RODAException(e); - } - TypeReference> typeRef = new TypeReference<>() {}; - Map confirmationValues = new ObjectMapper().readValue(output, typeRef); - InputStream templateStream; - - if (isToPrint) { - templateStream = RodaCoreFactory.getConfigurationFileAsStream( - RodaConstants.DISPOSAL_CONFIRMATION_INFORMATION_TEMPLATE_FOLDER + "/" + DISPOSAL_CONFIRMATION_REPORT_PRINT_HBS); - } else { - templateStream = RodaCoreFactory.getConfigurationFileAsStream( - RodaConstants.DISPOSAL_CONFIRMATION_INFORMATION_TEMPLATE_FOLDER + "/" + DISPOSAL_CONFIRMATION_REPORT_HBS); - } - - String reportTemplate = IOUtils.toString(templateStream, StandardCharsets.UTF_8); - - Handlebars handlebars = new Handlebars(); - handlebars.registerHelper(HBS_DATEFORMAT_HELPER_NAME, (Helper) (value, options) -> { - ZonedDateTime date = Instant.ofEpochMilli(value).atZone(ZoneOffset.UTC); - return DateTimeFormatter.ofPattern(DATETIME_FORMAT).format(date); - }); - Template template = handlebars.compileInline(reportTemplate); - String apply = template.apply(confirmationValues); - - final ConsumesOutputStream stream = new DefaultConsumesOutputStream(confirmationId + HTML_EXTENSION, - RodaConstants.MEDIA_TYPE_TEXT_HTML, out -> { - PrintStream printStream = new PrintStream(out); - printStream.print(apply); - printStream.close(); - }); - - return new StreamResponse(stream); + private String getExistingFileOrNull(String filePath) { + File file = new File(filePath); + return (file.exists() && file.isFile()) ? filePath : "/dev/null"; } - private Path getDisposalConfirmationMetadataPath(String confirmationId) throws RequestNotValidException { - DefaultStoragePath confirmationPath = DefaultStoragePath - .parse(ModelUtils.getDisposalConfirmationStoragePath(confirmationId)); + public StreamResponse createDisposalConfirmationReport(ModelService model, String confirmationId, boolean isToPrint) + throws RODAException, IOException { - Path entityPath = FSUtils.getEntityPath(RodaCoreFactory.getStoragePath(), confirmationPath); + StoragePath disposalConfirmationStoragePath = ModelUtils.getDisposalConfirmationStoragePath(confirmationId); - return entityPath.resolve(RodaConstants.STORAGE_DIRECTORY_DISPOSAL_CONFIRMATION_METADATA_FILENAME); - } + try (DirectResourceAccess disposalConfirmationDirectAccess = model.getStorage() + .getDirectAccess(disposalConfirmationStoragePath, true)) { + String jqCommandTemplate = RodaCoreFactory.getRodaConfigurationAsString(DISPOSAL_CONFIRMATION_COMMAND_PROPERTY); - private Path getDisposalConfirmationAIPsPath(String confirmationId) throws RequestNotValidException { - DefaultStoragePath confirmationPath = DefaultStoragePath - .parse(ModelUtils.getDisposalConfirmationStoragePath(confirmationId)); + Path schedulesDirectAccess = disposalConfirmationDirectAccess.getPath() + .resolve(RodaConstants.STORAGE_DIRECTORY_DISPOSAL_CONFIRMATION_SCHEDULES_FILENAME); + Path holdsDirectAccess = disposalConfirmationDirectAccess.getPath() + .resolve(RodaConstants.STORAGE_DIRECTORY_DISPOSAL_CONFIRMATION_HOLDS_FILENAME); + Path metadataDirectAccess = disposalConfirmationDirectAccess.getPath() + .resolve(RodaConstants.STORAGE_DIRECTORY_DISPOSAL_CONFIRMATION_METADATA_FILENAME); + Path aipsDirectAccess = disposalConfirmationDirectAccess.getPath() + .resolve(RodaConstants.STORAGE_DIRECTORY_DISPOSAL_CONFIRMATION_AIPS_FILENAME); - Path entityPath = FSUtils.getEntityPath(RodaCoreFactory.getStoragePath(), confirmationPath); + Map values = new HashMap<>(); - return entityPath.resolve(RodaConstants.STORAGE_DIRECTORY_DISPOSAL_CONFIRMATION_AIPS_FILENAME); - } + values.put(METADATA_FILE_PLACEHOLDER, getExistingFileOrNull(metadataDirectAccess.toString())); + values.put(AIPS_FILE_PLACEHOLDER, getExistingFileOrNull(aipsDirectAccess.toString())); + values.put(SCHEDULES_FILE_PLACEHOLDER, getExistingFileOrNull(schedulesDirectAccess.toString())); + values.put(HOLDS_FILE_PLACEHOLDER, getExistingFileOrNull(holdsDirectAccess.toString())); - private Path getDisposalConfirmationSchedulesPath(String confirmationId) throws RequestNotValidException { - DefaultStoragePath confirmationPath = DefaultStoragePath - .parse(ModelUtils.getDisposalConfirmationStoragePath(confirmationId)); + String jqCommandParams = HandlebarsUtility.executeHandlebars(jqCommandTemplate, values); - Path entityPath = FSUtils.getEntityPath(RodaCoreFactory.getStoragePath(), confirmationPath); + List jqCommand = new ArrayList<>(); + Collections.addAll(jqCommand, jqCommandParams.split(" ")); - return entityPath.resolve(RodaConstants.STORAGE_DIRECTORY_DISPOSAL_CONFIRMATION_SCHEDULES_FILENAME); - } + String output; + try { + output = CommandUtility.execute(jqCommand); + } catch (CommandException e) { + throw new RODAException(e); + } + TypeReference> typeRef = new TypeReference<>() {}; + Map confirmationValues = new ObjectMapper().readValue(output, typeRef); + InputStream templateStream; + + if (isToPrint) { + templateStream = RodaCoreFactory + .getConfigurationFileAsStream(RodaConstants.DISPOSAL_CONFIRMATION_INFORMATION_TEMPLATE_FOLDER + "/" + + DISPOSAL_CONFIRMATION_REPORT_PRINT_HBS); + } else { + templateStream = RodaCoreFactory.getConfigurationFileAsStream( + RodaConstants.DISPOSAL_CONFIRMATION_INFORMATION_TEMPLATE_FOLDER + "/" + DISPOSAL_CONFIRMATION_REPORT_HBS); + } - private Path getDisposalConfirmationHoldsPath(String confirmationId) throws RequestNotValidException { - DefaultStoragePath confirmationPath = DefaultStoragePath - .parse(ModelUtils.getDisposalConfirmationStoragePath(confirmationId)); + String reportTemplate = IOUtils.toString(templateStream, StandardCharsets.UTF_8); - Path entityPath = FSUtils.getEntityPath(RodaCoreFactory.getStoragePath(), confirmationPath); + Handlebars handlebars = new Handlebars(); + handlebars.registerHelper(HBS_DATEFORMAT_HELPER_NAME, (Helper) (value, options) -> { + ZonedDateTime date = Instant.ofEpochMilli(value).atZone(ZoneOffset.UTC); + return DateTimeFormatter.ofPattern(DATETIME_FORMAT).format(date); + }); + Template template = handlebars.compileInline(reportTemplate); + String apply = template.apply(confirmationValues); + + final ConsumesOutputStream stream = new DefaultConsumesOutputStream(confirmationId + HTML_EXTENSION, + RodaConstants.MEDIA_TYPE_TEXT_HTML, out -> { + PrintStream printStream = new PrintStream(out); + printStream.print(apply); + printStream.close(); + }); - return entityPath.resolve(RodaConstants.STORAGE_DIRECTORY_DISPOSAL_CONFIRMATION_HOLDS_FILENAME); + return new StreamResponse(stream); + } } public DisposalConfirmationForm retrieveDisposalConfirmationExtraBundle() { diff --git a/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/services/FilesService.java b/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/services/FilesService.java index ca34b0d495..d7bc4ea67b 100644 --- a/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/services/FilesService.java +++ b/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/services/FilesService.java @@ -66,6 +66,7 @@ import org.roda.core.storage.ContentPayload; import org.roda.core.storage.DirectResourceAccess; import org.roda.core.storage.RangeConsumesOutputStream; +import org.roda.core.storage.SeekableContentPayload; import org.roda.core.storage.utils.RODAInstanceUtils; import org.roda.core.util.IdUtils; import org.roda.wui.api.v2.utils.CommonServicesUtils; @@ -82,8 +83,8 @@ public class FilesService { private static final String HTML_EXT = ".html"; public IndexedFile renameFolder(RequestContext requestContext, IndexedFile indexedFolder, String newName, - String details) throws GenericException, RequestNotValidException, AlreadyExistsException, NotFoundException, - AuthorizationDeniedException { + String details) throws GenericException, RequestNotValidException, AlreadyExistsException, NotFoundException, + AuthorizationDeniedException { String eventDescription = "The process of updating an object of the repository."; User user = requestContext.getUser(); @@ -93,12 +94,12 @@ public IndexedFile renameFolder(RequestContext requestContext, IndexedFile index try { File folder = model.retrieveFile(indexedFolder.getAipId(), indexedFolder.getRepresentationId(), - indexedFolder.getPath(), indexedFolder.getId()); + indexedFolder.getPath(), indexedFolder.getId()); File newFolder = model.renameFolder(folder, newName, true); String outcomeText = "The folder '" + oldName + "' has been manually renamed to '" + newName + "'."; model.createUpdateAIPEvent(indexedFolder.getAipId(), indexedFolder.getRepresentationId(), null, null, - RodaConstants.PreservationEventType.UPDATE, eventDescription, PluginState.SUCCESS, outcomeText, details, - user.getName(), true, null); + RodaConstants.PreservationEventType.UPDATE, eventDescription, PluginState.SUCCESS, outcomeText, details, + user.getName(), true, null); index.commitAIPs(); return index.retrieve(IndexedFile.class, IdUtils.getFileId(newFolder), RodaConstants.FILE_FIELDS_TO_RETURN); @@ -106,41 +107,41 @@ public IndexedFile renameFolder(RequestContext requestContext, IndexedFile index String outcomeText = "The folder '" + oldName + "' has not been manually renamed to '" + newName + "'."; model.createUpdateAIPEvent(indexedFolder.getAipId(), indexedFolder.getRepresentationId(), null, null, - RodaConstants.PreservationEventType.UPDATE, eventDescription, PluginState.FAILURE, outcomeText, details, - user.getName(), true, null); + RodaConstants.PreservationEventType.UPDATE, eventDescription, PluginState.FAILURE, outcomeText, details, + user.getName(), true, null); throw e; } } public Job createFormatIdentificationJob(User user, SelectedItems selected) - throws GenericException, RequestNotValidException, NotFoundException, AuthorizationDeniedException { + throws GenericException, RequestNotValidException, NotFoundException, AuthorizationDeniedException { return CommonServicesUtils.createAndExecuteJob("Format identification using Siegfried", selected, - SiegfriedPlugin.class, PluginType.MISC, user, Collections.emptyMap(), - "Could not execute format identification using Siegfrid action"); + SiegfriedPlugin.class, PluginType.MISC, user, Collections.emptyMap(), + "Could not execute format identification using Siegfrid action"); } public Job deleteFiles(User user, DeleteRequest request) - throws AuthorizationDeniedException, GenericException, RequestNotValidException, NotFoundException { + throws AuthorizationDeniedException, GenericException, RequestNotValidException, NotFoundException { Map pluginParameters = new HashMap<>(); pluginParameters.put(RodaConstants.PLUGIN_PARAMS_DETAILS, request.getDetails()); return CommonServicesUtils.createAndExecuteInternalJob("Delete files", - CommonServicesUtils.convertSelectedItems(request.getItemsToDelete(), IndexedFile.class), - DeleteRODAObjectPlugin.class, user, pluginParameters, "Could not execute file delete action"); + CommonServicesUtils.convertSelectedItems(request.getItemsToDelete(), IndexedFile.class), + DeleteRODAObjectPlugin.class, user, pluginParameters, "Could not execute file delete action"); } public Job moveFiles(RequestContext requestContext, MoveFilesRequest request) - throws GenericException, RequestNotValidException, NotFoundException, AuthorizationDeniedException { + throws GenericException, RequestNotValidException, NotFoundException, AuthorizationDeniedException { User user = requestContext.getUser(); IndexService indexService = requestContext.getIndexService(); IndexedFile fileToMove = null; if (request.getFileUUIDtoMove() != null) { fileToMove = indexService.retrieve(IndexedFile.class, request.getFileUUIDtoMove(), - RodaConstants.FILE_FIELDS_TO_RETURN); + RodaConstants.FILE_FIELDS_TO_RETURN); } if (fileToMove != null && (!fileToMove.getAipId().equals(request.getAipId()) - || !fileToMove.getRepresentationId().equals(request.getRepresentationId()))) { + || !fileToMove.getRepresentationId().equals(request.getRepresentationId()))) { throw new RequestNotValidException("Cannot move to a file outside defined representation"); } @@ -151,12 +152,12 @@ public Job moveFiles(RequestContext requestContext, MoveFilesRequest request) pluginParameters.put(RodaConstants.PLUGIN_PARAMS_DETAILS, request.getDetails()); return CommonServicesUtils.createAndExecuteInternalJob("Move files", request.getItemsToMove(), MovePlugin.class, - user, pluginParameters, "Could not execute move job"); + user, pluginParameters, "Could not execute move job"); } public File createFile(RequestContext requestContext, String aipId, String representationId, - List directoryPath, String fileId, ContentPayload content, String details) throws GenericException, - AuthorizationDeniedException, RequestNotValidException, NotFoundException, AlreadyExistsException { + List directoryPath, String fileId, ContentPayload content, String details) throws GenericException, + AuthorizationDeniedException, RequestNotValidException, NotFoundException, AlreadyExistsException { String eventDescription = "The process of creating an object of the repository."; User user = requestContext.getUser(); @@ -167,27 +168,27 @@ public File createFile(RequestContext requestContext, String aipId, String repre List targets = new ArrayList<>(); targets.add(PluginHelper.getLinkingIdentifier(aipId, file.getRepresentationId(), file.getPath(), file.getId(), - RodaConstants.PRESERVATION_LINKING_OBJECT_OUTCOME)); + RodaConstants.PRESERVATION_LINKING_OBJECT_OUTCOME)); String outcomeText = "The file '" + file.getId() + "' has been manually created."; model.createEvent(aipId, representationId, null, null, RodaConstants.PreservationEventType.CREATION, - eventDescription, null, targets, PluginState.SUCCESS, outcomeText, details, user.getName(), true, null); + eventDescription, null, targets, PluginState.SUCCESS, outcomeText, details, user.getName(), true, null); requestContext.getIndexService().commit(IndexedFile.class); return file; } catch (RequestNotValidException | NotFoundException | GenericException | AuthorizationDeniedException - | AlreadyExistsException e) { + | AlreadyExistsException e) { String outcomeText = "The file '" + fileId + "' has not been manually created."; model.createUpdateAIPEvent(aipId, representationId, null, null, RodaConstants.PreservationEventType.CREATION, - eventDescription, PluginState.FAILURE, outcomeText, details, user.getName(), true, null); + eventDescription, PluginState.FAILURE, outcomeText, details, user.getName(), true, null); throw e; } } public IndexedFile createFolder(RequestContext requestContext, IndexedRepresentation indexedRepresentation, - CreateFolderRequest request) throws GenericException, RequestNotValidException, AlreadyExistsException, - NotFoundException, AuthorizationDeniedException { + CreateFolderRequest request) throws GenericException, RequestNotValidException, AlreadyExistsException, + NotFoundException, AuthorizationDeniedException { String eventDescription = "The process of creating an object of the repository."; User user = requestContext.getUser(); @@ -203,40 +204,44 @@ public IndexedFile createFolder(RequestContext requestContext, IndexedRepresenta if (folderUUID != null) { IndexedFile indexedFile = index.retrieve(IndexedFile.class, folderUUID, RodaConstants.FILE_FIELDS_TO_RETURN); newFolder = model.createFile(indexedFile.getAipId(), indexedFile.getRepresentationId(), indexedFile.getPath(), - indexedFile.getId(), folderName, user.getId(), true); + indexedFile.getId(), folderName, user.getId(), true); } else { newFolder = model.createFile(indexedRepresentation.getAipId(), indexedRepresentation.getId(), null, null, - folderName, user.getId(), true); + folderName, user.getId(), true); } String outcomeText = "The folder '" + folderName + "' has been manually created."; model.createUpdateAIPEvent(indexedRepresentation.getAipId(), indexedRepresentation.getId(), null, null, - RodaConstants.PreservationEventType.CREATION, eventDescription, PluginState.SUCCESS, outcomeText, details, - user.getName(), true, null); + RodaConstants.PreservationEventType.CREATION, eventDescription, PluginState.SUCCESS, outcomeText, details, + user.getName(), true, null); index.commit(IndexedFile.class); return index.retrieve(IndexedFile.class, IdUtils.getFileId(newFolder), new ArrayList<>()); } catch (RequestNotValidException | NotFoundException | GenericException | AuthorizationDeniedException e) { String outcomeText = "The folder '" + folderName + "' has not been manually created."; model.createUpdateAIPEvent(indexedRepresentation.getAipId(), indexedRepresentation.getId(), null, null, - RodaConstants.PreservationEventType.CREATION, eventDescription, PluginState.FAILURE, outcomeText, details, - user.getName(), true, null); + RodaConstants.PreservationEventType.CREATION, eventDescription, PluginState.FAILURE, outcomeText, details, + user.getName(), true, null); throw e; } } public RangeConsumesOutputStream retrieveAIPRepresentationRangeStream(RequestContext requestContext, - IndexedFile indexedFile) - throws AuthorizationDeniedException, RequestNotValidException, NotFoundException, GenericException { + IndexedFile indexedFile) + throws AuthorizationDeniedException, RequestNotValidException, NotFoundException, GenericException { ModelService model = requestContext.getModelService(); if (!indexedFile.isDirectory()) { final RangeConsumesOutputStream stream; - DirectResourceAccess directFileAccess = model.getDirectAccess(indexedFile); - if (indexedFile.getFileFormat() != null && StringUtils.isNotBlank(indexedFile.getFileFormat().getMimeType())) { - stream = new RangeConsumesOutputStream(directFileAccess.getPath(), indexedFile.getFileFormat().getMimeType()); + Binary binary = model.getBinary(indexedFile); + ContentPayload payload = binary.getContent(); + + if (payload instanceof SeekableContentPayload) { + SeekableContentPayload seekable = (SeekableContentPayload) payload; + + stream = new RangeConsumesOutputStream(seekable, binary, indexedFile.getFileFormat().getMimeType()); } else { - stream = new RangeConsumesOutputStream(directFileAccess.getPath()); + throw new RequestNotValidException("Range stream for file unsupported"); } return stream; } else { @@ -245,7 +250,7 @@ public RangeConsumesOutputStream retrieveAIPRepresentationRangeStream(RequestCon } public StreamResponse retrieveAIPRepresentationFile(RequestContext requestContext, IndexedFile indexedFile) - throws GenericException, RequestNotValidException, NotFoundException, AuthorizationDeniedException { + throws GenericException, RequestNotValidException, NotFoundException, AuthorizationDeniedException { ModelService model = requestContext.getModelService(); List ids = new ArrayList<>(); ids.add(indexedFile.getAipId()); @@ -273,7 +278,7 @@ public StreamResponse retrieveAIPRepresentationFile(RequestContext requestContex } public Optional retrieveDistributedInstanceName(RequestContext requestContext, String instanceId, - boolean isLocalInstance) { + boolean isLocalInstance) { try { ModelService model = requestContext.getModelService(); RodaConstants.DistributedModeType distributedModeType = RodaCoreFactory.getDistributedModeType(); @@ -281,7 +286,7 @@ public Optional retrieveDistributedInstanceName(RequestContext requestCo if (RodaConstants.DistributedModeType.CENTRAL.equals(distributedModeType)) { if (isLocalInstance) { return Optional.of(RodaCoreFactory.getProperty(RodaConstants.CENTRAL_INSTANCE_NAME_PROPERTY, - RodaConstants.DEFAULT_CENTRAL_INSTANCE_NAME)); + RodaConstants.DEFAULT_CENTRAL_INSTANCE_NAME)); } else { DistributedInstance distributedInstance = model.retrieveDistributedInstance(instanceId); return Optional.of(distributedInstance.getName()); @@ -313,30 +318,30 @@ public boolean isShallowFileAvailable(IndexedFile indexedFile) { public List getConfigurationFileRules(User user) { if (UserUtility.hasPermissions(user, RodaConstants.PERMISSION_METHOD_FIND_REPRESENTATION_INFORMATION)) { return RodaCoreFactory.getRodaConfigurationAsList("ui.ri.rule.File").stream() - .map(r -> RodaCoreFactory.getRodaConfigurationAsString(r, RodaConstants.SEARCH_FIELD_FIELDS)).toList(); + .map(r -> RodaCoreFactory.getRodaConfigurationAsString(r, RodaConstants.SEARCH_FIELD_FIELDS)).toList(); } else { return Collections.emptyList(); } } public StreamResponse retrieveFilePreservationHTML(RequestContext requestContext, IndexedFile file, String language) - throws GenericException, RequestNotValidException, NotFoundException, AuthorizationDeniedException, - TechnicalMetadataNotFoundException { + throws GenericException, RequestNotValidException, NotFoundException, AuthorizationDeniedException, + TechnicalMetadataNotFoundException { final String filename; final ConsumesOutputStream stream; StreamResponse ret; ModelService model = requestContext.getModelService(); Binary preservationMetadataBinary = model.retrievePreservationFile(file.getAipId(), file.getRepresentationId(), - file.getAncestorsPath(), file.getId()); + file.getAncestorsPath(), file.getId()); filename = preservationMetadataBinary.getStoragePath().getName() + HTML_EXT; List parameters = PremisV3Utils.getApplicationTechnicalMetadataParameters(model, file.getAipId(), - file.getRepresentationId(), file.getAncestorsPath(), file.getId()); + file.getRepresentationId(), file.getAncestorsPath(), file.getId()); // PremisV3Utils StringBuilder htmlTechnical = new StringBuilder(); for (int i = 0; i < parameters.size(); i += 2) { htmlTechnical.append(HTMLUtils.technicalMetadataToHtml(preservationMetadataBinary, parameters.get(i), - parameters.get(i + 1), ServerTools.parseLocale(language))); + parameters.get(i + 1), ServerTools.parseLocale(language))); } stream = new DefaultConsumesOutputStream(filename, RodaConstants.MEDIA_TYPE_TEXT_HTML, out -> { PrintStream printStream = new PrintStream(out); @@ -350,14 +355,14 @@ public StreamResponse retrieveFilePreservationHTML(RequestContext requestContext } public StreamResponse retrieveFilePreservationFile(RequestContext requestContext, IndexedFile file) - throws GenericException, RequestNotValidException, NotFoundException, AuthorizationDeniedException, - TechnicalMetadataNotFoundException { + throws GenericException, RequestNotValidException, NotFoundException, AuthorizationDeniedException, + TechnicalMetadataNotFoundException { final ConsumesOutputStream stream; StreamResponse ret; ModelService model = requestContext.getModelService(); Binary preservationMetadataBinary = model.retrievePreservationFile(file.getAipId(), file.getRepresentationId(), - file.getAncestorsPath(), file.getId()); + file.getAncestorsPath(), file.getId()); stream = new BinaryConsumesOutputStream(preservationMetadataBinary, RodaConstants.MEDIA_TYPE_TEXT_XML); ret = new StreamResponse(stream); @@ -366,8 +371,8 @@ public StreamResponse retrieveFilePreservationFile(RequestContext requestContext } public TechnicalMetadataInfos retrieveFileTechnicalMetadataInfos(RequestContext requestContext, IndexedFile file, - String localeString) - throws AuthorizationDeniedException, RequestNotValidException, NotFoundException, GenericException { + String localeString) + throws AuthorizationDeniedException, RequestNotValidException, NotFoundException, GenericException { TechnicalMetadataInfos technicalMetadataInfos = new TechnicalMetadataInfos(); ModelService model = requestContext.getModelService(); @@ -386,7 +391,8 @@ public TechnicalMetadataInfos retrieveFileTechnicalMetadataInfos(RequestContext String type = technicalMetadata.getType(); if (techMdTypes.contains(type.toLowerCase())) { String label = messages.getTranslation( - RodaConstants.I18N_UI_BROWSE_METADATA_TECHNICAL_TYPE_PREFIX + type.toLowerCase(), technicalMetadata.getId()); + RodaConstants.I18N_UI_BROWSE_METADATA_TECHNICAL_TYPE_PREFIX + type.toLowerCase(), + technicalMetadata.getId()); technicalMetadataInfos.addObject(new TechnicalMetadataInfo(type, label)); } } @@ -394,57 +400,59 @@ public TechnicalMetadataInfos retrieveFileTechnicalMetadataInfos(RequestContext } public StreamResponse retrieveFileTechnicalMetadataHTML(RequestContext requestContext, IndexedFile file, String type, - String versionID, String localeString) throws RequestNotValidException, AuthorizationDeniedException, - NotFoundException, GenericException, TechnicalMetadataNotFoundException { + String versionID, String localeString) throws RequestNotValidException, AuthorizationDeniedException, + NotFoundException, GenericException, TechnicalMetadataNotFoundException { ModelService model = requestContext.getModelService(); Representation representation = model.retrieveRepresentation(file.getAipId(), file.getRepresentationId()); - String techMDURN = URNUtils.createRodaTechnicalMetadataURN(IdUtils.createTechnicalMetadataFileId(file.getId(), file.getPath()), - RODAInstanceUtils.getLocalInstanceIdentifier(), type.toLowerCase()); + String techMDURN = URNUtils.createRodaTechnicalMetadataURN( + IdUtils.createTechnicalMetadataFileId(file.getId(), file.getPath()), + RODAInstanceUtils.getLocalInstanceIdentifier(), type.toLowerCase()); Binary metadataBinary; if (versionID != null) { BinaryVersion binaryVersion = model.getBinaryVersion(representation, versionID, - List.of(RodaConstants.STORAGE_DIRECTORY_METADATA, RodaConstants.STORAGE_DIRECTORY_TECHNICAL, type, - techMDURN)); + List.of(RodaConstants.STORAGE_DIRECTORY_METADATA, RodaConstants.STORAGE_DIRECTORY_TECHNICAL, type, + techMDURN)); metadataBinary = binaryVersion.getBinary(); } else { metadataBinary = model.getBinary(representation, RodaConstants.STORAGE_DIRECTORY_METADATA, - RodaConstants.STORAGE_DIRECTORY_TECHNICAL, type, - techMDURN); + RodaConstants.STORAGE_DIRECTORY_TECHNICAL, type, + techMDURN); } String filename = metadataBinary.getStoragePath().getName() + HTML_EXT; String htmlDescriptive = HTMLUtils.technicalMetadataToHtml(metadataBinary, type, versionID, - ServerTools.parseLocale(localeString)); + ServerTools.parseLocale(localeString)); ConsumesOutputStream stream = new DefaultConsumesOutputStream(filename, RodaConstants.MEDIA_TYPE_APPLICATION_XML, - out -> { - PrintStream printStream = new PrintStream(out); - printStream.print(htmlDescriptive); - printStream.close(); - }); + out -> { + PrintStream printStream = new PrintStream(out); + printStream.print(htmlDescriptive); + printStream.close(); + }); return new StreamResponse(stream); } public StreamResponse retrieveFileTechnicalMetadata(RequestContext requestContext, IndexedFile file, String type, - String versionID) - throws GenericException, RequestNotValidException, NotFoundException, AuthorizationDeniedException { + String versionID) + throws GenericException, RequestNotValidException, NotFoundException, AuthorizationDeniedException { final ConsumesOutputStream stream; StreamResponse ret; ModelService model = requestContext.getModelService(); Representation representation = model.retrieveRepresentation(file.getAipId(), file.getRepresentationId()); - String techMDURN = URNUtils.createRodaTechnicalMetadataURN(IdUtils.createTechnicalMetadataFileId(file.getId(), file.getPath()), - RODAInstanceUtils.getLocalInstanceIdentifier(), type.toLowerCase()); + String techMDURN = URNUtils.createRodaTechnicalMetadataURN( + IdUtils.createTechnicalMetadataFileId(file.getId(), file.getPath()), + RODAInstanceUtils.getLocalInstanceIdentifier(), type.toLowerCase()); Binary metadataBinary; if (versionID != null) { BinaryVersion binaryVersion = model.getBinaryVersion(representation, versionID, - List.of(RodaConstants.STORAGE_DIRECTORY_METADATA, RodaConstants.STORAGE_DIRECTORY_TECHNICAL, type, - techMDURN)); + List.of(RodaConstants.STORAGE_DIRECTORY_METADATA, RodaConstants.STORAGE_DIRECTORY_TECHNICAL, type, + techMDURN)); metadataBinary = binaryVersion.getBinary(); } else { metadataBinary = model.getBinary(representation, RodaConstants.STORAGE_DIRECTORY_METADATA, - RodaConstants.STORAGE_DIRECTORY_TECHNICAL, type, - techMDURN); + RodaConstants.STORAGE_DIRECTORY_TECHNICAL, type, + techMDURN); } stream = new BinaryConsumesOutputStream(metadataBinary, RodaConstants.MEDIA_TYPE_TEXT_XML); @@ -454,12 +462,12 @@ public StreamResponse retrieveFileTechnicalMetadata(RequestContext requestContex } public StreamResponse retrieveOtherMetadata(RequestContext requestContext, IndexedFile file, String metadataType, - String metadataSuffix) - throws AuthorizationDeniedException, RequestNotValidException, NotFoundException, GenericException { + String metadataSuffix) + throws AuthorizationDeniedException, RequestNotValidException, NotFoundException, GenericException { final ConsumesOutputStream stream; ModelService model = requestContext.getModelService(); Binary otherMetadataBinary = model.retrieveOtherMetadataBinary(file.getAipId(), file.getRepresentationId(), - file.getPath(), file.getId(), metadataSuffix, metadataType); + file.getPath(), file.getId(), metadataSuffix, metadataType); stream = new BinaryConsumesOutputStream(otherMetadataBinary, RodaConstants.MEDIA_TYPE_TEXT_HTML); return new StreamResponse(stream); } diff --git a/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/services/JobService.java b/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/services/JobService.java index ff58be568f..148bf1db30 100644 --- a/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/services/JobService.java +++ b/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/services/JobService.java @@ -246,7 +246,7 @@ public JobUserDetails buildJobUserDetails(User user) { public StreamResponse retrieveJobAttachment(String jobId, String attachmentId) throws NotFoundException, GenericException { Path filePath = RodaCoreFactory.getJobAttachmentsDirectoryPath().resolve(jobId).resolve(attachmentId); - if (!RodaCoreFactory.getJobAttachmentsDirectoryPath().startsWith(filePath)) { + if (!filePath.startsWith(RodaCoreFactory.getJobAttachmentsDirectoryPath())) { throw new GenericException("Attempt to retrieve files outside the permitted scope"); } diff --git a/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/utils/ApiUtils.java b/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/utils/ApiUtils.java index aab0c6a4c1..4d2cd32ef5 100644 --- a/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/utils/ApiUtils.java +++ b/roda-ui/roda-wui/src/main/java/org/roda/wui/api/v2/utils/ApiUtils.java @@ -64,15 +64,15 @@ public static ResponseEntity okResponse(StreamResponse st } public static ResponseEntity rangeResponse(HttpHeaders headers, - RangeConsumesOutputStream consumesOutputStream) { + RangeConsumesOutputStream consumesOutputStream, boolean inline) { final HttpHeaders responseHeaders = new HttpHeaders(); StreamingResponseBody responseStream; if (headers.getRange().isEmpty()) { responseStream = consumesOutputStream::consumeOutputStream; responseHeaders.add("Content-Type", consumesOutputStream.getMediaType()); - responseHeaders.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" - + URLEncoder.encode(consumesOutputStream.getFileName(), StandardCharsets.UTF_8) + "\""); + responseHeaders.add(HttpHeaders.CONTENT_DISPOSITION, inline ? "inline; " : "attachment; " + "filename=\"" + + URLEncoder.encode(consumesOutputStream.getFileName(), StandardCharsets.UTF_8) + "\""); responseHeaders.add("Content-Length", String.valueOf(consumesOutputStream.getSize())); return ResponseEntity.ok().headers(responseHeaders).body(responseStream); @@ -86,10 +86,10 @@ public static ResponseEntity rangeResponse(HttpHeaders he responseHeaders.add(HttpHeaders.CONTENT_TYPE, consumesOutputStream.getMediaType()); responseHeaders.add(HttpHeaders.CONTENT_LENGTH, contentLength); responseHeaders.add(HttpHeaders.CONTENT_DISPOSITION, - "inline; filename=\"" + URLEncoder.encode(consumesOutputStream.getFileName(), StandardCharsets.UTF_8) + "\""); + "inline; filename=\"" + URLEncoder.encode(consumesOutputStream.getFileName(), StandardCharsets.UTF_8) + "\""); responseHeaders.add(HttpHeaders.ACCEPT_RANGES, "bytes"); responseHeaders.add(HttpHeaders.CONTENT_RANGE, - "bytes" + " " + start + "-" + end + "/" + consumesOutputStream.getSize()); + "bytes" + " " + start + "-" + end + "/" + consumesOutputStream.getSize()); responseStream = os -> (consumesOutputStream).consumeOutputStream(os, start, end); @@ -104,6 +104,11 @@ public static ResponseEntity rangeResponse(HttpHeaders he return new ResponseEntity<>(responseStream, responseHeaders, HttpStatus.PARTIAL_CONTENT); } + public static ResponseEntity rangeResponse(HttpHeaders headers, + RangeConsumesOutputStream consumesOutputStream) { + return rangeResponse(headers, consumesOutputStream, false); + } + public static StreamResponse download(RequestContext requestContext, IsRODAObject object, String... pathPartials) throws GenericException, RequestNotValidException, NotFoundException, AuthorizationDeniedException { return download(requestContext, object, null, false, pathPartials); diff --git a/roda-ui/roda-wui/src/main/java/org/roda/wui/client/browse/BrowseRepresentation.java b/roda-ui/roda-wui/src/main/java/org/roda/wui/client/browse/BrowseRepresentation.java index 2bc3ff8d4f..6d26338535 100644 --- a/roda-ui/roda-wui/src/main/java/org/roda/wui/client/browse/BrowseRepresentation.java +++ b/roda-ui/roda-wui/src/main/java/org/roda/wui/client/browse/BrowseRepresentation.java @@ -193,7 +193,7 @@ public BrowseRepresentation(BrowseRepresentationResponse response) { // CARDS if (dipCounterResponse.getResult() > 0) { - this.disseminationCards.add(new RepresentationDisseminationCardList(aipId, repId, SorterUtils.representationDefault(), dipCounterResponse.getResult().intValue())); + this.disseminationCards.add(new RepresentationDisseminationCardList(aipId, repUUID, SorterUtils.representationDefault(), dipCounterResponse.getResult().intValue())); } else { this.sidePanel.setVisible(false); } diff --git a/roda-ui/roda-wui/src/main/java/org/roda/wui/client/browse/tabs/BrowseFileTabs.java b/roda-ui/roda-wui/src/main/java/org/roda/wui/client/browse/tabs/BrowseFileTabs.java index 580de7ca0f..56cfcbd20f 100644 --- a/roda-ui/roda-wui/src/main/java/org/roda/wui/client/browse/tabs/BrowseFileTabs.java +++ b/roda-ui/roda-wui/src/main/java/org/roda/wui/client/browse/tabs/BrowseFileTabs.java @@ -29,6 +29,8 @@ import com.google.gwt.safehtml.shared.SafeHtmlUtils; import com.google.gwt.user.client.ui.Widget; +import java.util.Arrays; + /** * * @author Alexandre Flores @@ -67,10 +69,16 @@ public Widget buildTabWidget() { @Override public Widget buildTabWidget() { SearchWrapper riskIncidences = new SearchWrapper(false); - riskIncidences.createListAndSearchPanel(new ListBuilder<>(() -> new RiskIncidenceList(), - new AsyncTableCellOptions<>(RiskIncidence.class, "BrowseFile_riskIncidences") - .withFilter(new Filter(new SimpleFilterParameter(RodaConstants.RISK_INCIDENCE_FILE_ID, file.getId()))) - .withJustActive(justActive).bindOpener())); + riskIncidences + .createListAndSearchPanel( + new ListBuilder<>(() -> new RiskIncidenceList(), + new AsyncTableCellOptions<>(RiskIncidence.class, "BrowseFile_riskIncidences") + .withFilter(new Filter( + Arrays.asList(new SimpleFilterParameter(RodaConstants.RISK_INCIDENCE_FILE_ID, file.getId()), + new SimpleFilterParameter(RodaConstants.RISK_INCIDENCE_REPRESENTATION_ID, + file.getRepresentationId()), + new SimpleFilterParameter(RodaConstants.RISK_INCIDENCE_AIP_ID, file.getAipId())))) + .withJustActive(justActive).bindOpener())); return riskIncidences; } }); diff --git a/roda-ui/roda-wui/src/main/java/org/roda/wui/client/browse/tabs/BrowseRepresentationTabs.java b/roda-ui/roda-wui/src/main/java/org/roda/wui/client/browse/tabs/BrowseRepresentationTabs.java index 245ec83692..c2930ed837 100644 --- a/roda-ui/roda-wui/src/main/java/org/roda/wui/client/browse/tabs/BrowseRepresentationTabs.java +++ b/roda-ui/roda-wui/src/main/java/org/roda/wui/client/browse/tabs/BrowseRepresentationTabs.java @@ -30,6 +30,8 @@ import com.google.gwt.safehtml.shared.SafeHtmlUtils; import com.google.gwt.user.client.ui.Widget; +import java.util.Arrays; + /** * * @author Alexandre Flores @@ -54,8 +56,8 @@ public Widget buildTabWidget() { ListBuilder fileListBuilder = new ListBuilder<>(() -> new ConfigurableAsyncTableCell<>(), new AsyncTableCellOptions<>(IndexedFile.class, "BrowseRepresentation_files").withFilter(filesFilter) - .withJustActive(justActive).withSummary(summary).bindOpener() - .withActionable(FileSearchWrapperActions.get(aip.getId(), representation.getId(), aip.getState(), aip.getPermissions()))); + .withJustActive(justActive).withSummary(summary).bindOpener().withActionable( + FileSearchWrapperActions.get(aip.getId(), representation.getId(), aip.getState(), aip.getPermissions()))); return new SearchWrapper(false).createListAndSearchPanel(fileListBuilder); } @@ -94,8 +96,9 @@ public Widget buildTabWidget() { SearchWrapper riskIncidences = new SearchWrapper(false); riskIncidences.createListAndSearchPanel(new ListBuilder<>(() -> new RiskIncidenceList(), new AsyncTableCellOptions<>(RiskIncidence.class, "BrowseRepresentation_riskIncidences") - .withFilter(new Filter( - new SimpleFilterParameter(RodaConstants.RISK_INCIDENCE_REPRESENTATION_ID, representation.getId()))) + .withFilter(new Filter(Arrays.asList( + new SimpleFilterParameter(RodaConstants.RISK_INCIDENCE_REPRESENTATION_ID, representation.getId()), + new SimpleFilterParameter(RodaConstants.RISK_INCIDENCE_AIP_ID, representation.getAipId())))) .withJustActive(justActive).bindOpener())); return riskIncidences; } diff --git a/roda-ui/roda-wui/src/main/java/org/roda/wui/client/common/cards/RepresentationDisseminationCardList.java b/roda-ui/roda-wui/src/main/java/org/roda/wui/client/common/cards/RepresentationDisseminationCardList.java index b1d037bbbc..76d33df1f2 100644 --- a/roda-ui/roda-wui/src/main/java/org/roda/wui/client/common/cards/RepresentationDisseminationCardList.java +++ b/roda-ui/roda-wui/src/main/java/org/roda/wui/client/common/cards/RepresentationDisseminationCardList.java @@ -35,10 +35,10 @@ * @author Alexandre Flores */ public class RepresentationDisseminationCardList extends ThumbnailCardList { - public RepresentationDisseminationCardList(String aipId, String representationId, Sorter sorter, int cardsTotal) { + public RepresentationDisseminationCardList(String aipId, String representationUUID, Sorter sorter, int cardsTotal) { super(messages.someOfAObject(IndexedDIP.class.getName()), ConfigurationManager.getString(RodaConstants.UI_ICONS_CLASS, IndexedDIP.class.getSimpleName()), IndexedDIP.class, - new Filter(new SimpleFilterParameter(RodaConstants.DIP_REPRESENTATION_IDS, representationId)), + new Filter(new SimpleFilterParameter(RodaConstants.DIP_REPRESENTATION_UUIDS, representationUUID)), new CardBuilder() { @Override public ThumbnailCard constructCard(ClientMessages messages, IndexedDIP dip) { diff --git a/roda-ui/roda-wui/src/main/java/org/roda/wui/client/common/resources/main.gss b/roda-ui/roda-wui/src/main/java/org/roda/wui/client/common/resources/main.gss index 1e0d6290db..a6d6ec18f6 100644 --- a/roda-ui/roda-wui/src/main/java/org/roda/wui/client/common/resources/main.gss +++ b/roda-ui/roda-wui/src/main/java/org/roda/wui/client/common/resources/main.gss @@ -7601,6 +7601,10 @@ td.datePickerMonth, td.datePickerYear { max-width: 100%; } +.value.ri-html-content { + white-space: pre-wrap; +} + .field .value.ri-description { white-space: pre-wrap; } diff --git a/roda-ui/roda-wui/src/main/java/org/roda/wui/client/disposal/association/DisposalPolicyAssociationTab.java b/roda-ui/roda-wui/src/main/java/org/roda/wui/client/disposal/association/DisposalPolicyAssociationTab.java index bc4c0c2031..230757de8e 100644 --- a/roda-ui/roda-wui/src/main/java/org/roda/wui/client/disposal/association/DisposalPolicyAssociationTab.java +++ b/roda-ui/roda-wui/src/main/java/org/roda/wui/client/disposal/association/DisposalPolicyAssociationTab.java @@ -95,6 +95,14 @@ private boolean showNotAssigned(IndexedAIP aip) { return false; } + if (!aip.getDisposalHoldsId().isEmpty()) { + return false; + } + + if (!aip.getTransitiveDisposalHoldsId().isEmpty()) { + return false; + } + return !StringUtils.isNotBlank(aip.getDisposalScheduleId()); } diff --git a/roda-ui/roda-wui/src/main/java/org/roda/wui/client/ingest/process/PluginParameterPanel.java b/roda-ui/roda-wui/src/main/java/org/roda/wui/client/ingest/process/PluginParameterPanel.java index b6d100c2e5..d68902f4aa 100644 --- a/roda-ui/roda-wui/src/main/java/org/roda/wui/client/ingest/process/PluginParameterPanel.java +++ b/roda-ui/roda-wui/src/main/java/org/roda/wui/client/ingest/process/PluginParameterPanel.java @@ -246,12 +246,10 @@ private FlowPanel createRepresentationType(String name, String description, selectBox.addItem(option); } - if (options.isControlledVocabulary()) { - selectBox.addItem(messages.entityTypeAddNew(), ADD_TYPE); - } + selectBox.addItem(messages.entityTypeAddNew(), ADD_TYPE); selectBox.setSelectedIndex(0); value = selectBox.getSelectedValue(); - representationParameter.setValue(selectBox.getSelectedValue()); + representationParameter.setValue(value); } }); diff --git a/roda-ui/roda-wui/src/main/java/org/roda/wui/common/client/tools/RestUtils.java b/roda-ui/roda-wui/src/main/java/org/roda/wui/common/client/tools/RestUtils.java index 7221fbc2fc..2e6a34a6e8 100644 --- a/roda-ui/roda-wui/src/main/java/org/roda/wui/common/client/tools/RestUtils.java +++ b/roda-ui/roda-wui/src/main/java/org/roda/wui/common/client/tools/RestUtils.java @@ -60,8 +60,8 @@ public static SafeUri createRepresentationDownloadUri(String aipId, String repre // api/v2/{aip_id}/representations/{representation_id}/binary StringBuilder b = new StringBuilder(); // base uri - b.append(RodaConstants.API_REST_V2_AIPS).append(URL.encodeQueryString(aipId)) - .append(RodaConstants.API_SEP).append(RodaConstants.AIP_REPRESENTATIONS).append(RodaConstants.API_SEP) + b.append(RodaConstants.API_REST_V2_AIPS).append(URL.encodeQueryString(aipId)).append(RodaConstants.API_SEP) + .append(RodaConstants.AIP_REPRESENTATIONS).append(RodaConstants.API_SEP) .append(URL.encodeQueryString(representationId)).append(RodaConstants.API_REST_V2_DOWNLOAD_HANDLER); return UriUtils.fromSafeConstant(b.toString()); @@ -72,8 +72,8 @@ public static SafeUri createRepresentationOtherMetadataDownloadUri(String aipId, // api/v2/aip/{aip_id}/representations/{representation_id}/other-metadata/binary StringBuilder b = new StringBuilder(); // base uri - b.append(RodaConstants.API_REST_V2_AIPS).append(URL.encodeQueryString(aipId)) - .append(RodaConstants.API_SEP).append(RodaConstants.AIP_REPRESENTATIONS).append(RodaConstants.API_SEP) + b.append(RodaConstants.API_REST_V2_AIPS).append(URL.encodeQueryString(aipId)).append(RodaConstants.API_SEP) + .append(RodaConstants.AIP_REPRESENTATIONS).append(RodaConstants.API_SEP) .append(URL.encodeQueryString(representationId)).append(RodaConstants.API_REST_V2_REPRESENTATION_OTHER_METADATA) .append(RodaConstants.API_REST_V2_REPRESENTATION_BINARY); @@ -84,20 +84,22 @@ public static SafeUri createRepresentationOtherMetadataDownloadUri(String aipId, return UriUtils.fromSafeConstant(b.toString()); } - public static SafeUri createRepresentationFilePreviewUri(String fileUuid, boolean contentDispositionInline) { + public static SafeUri createRepresentationFilePreviewUri(String fileUuid, boolean contentDispositionInline) { // api/v2/files/{file_uuid}/preview String b = RodaConstants.API_REST_V2_FILES + URL.encodeQueryString(fileUuid) - + RodaConstants.API_REST_V2_PREVIEW_HANDLER; + + RodaConstants.API_REST_V2_PREVIEW_HANDLER + RodaConstants.API_QUERY_START + RodaConstants.API_QUERY_PARAM_INLINE + + RodaConstants.API_QUERY_ASSIGN_SYMBOL + contentDispositionInline; return UriUtils.fromSafeConstant(b); } - public static SafeUri createRepresentationFileDownloadUri(String fileUuid){ - // api/v2/files/{file_uuid}/download - StringBuilder b = new StringBuilder(); - b.append(RodaConstants.API_REST_V2_FILES).append(URL.encodeQueryString(fileUuid)).append(RodaConstants.API_REST_V2_DOWNLOAD_HANDLER); + public static SafeUri createRepresentationFileDownloadUri(String fileUuid) { + // api/v2/files/{file_uuid}/download + StringBuilder b = new StringBuilder(); + b.append(RodaConstants.API_REST_V2_FILES).append(URL.encodeQueryString(fileUuid)) + .append(RodaConstants.API_REST_V2_DOWNLOAD_HANDLER); - return UriUtils.fromSafeConstant(b.toString()); + return UriUtils.fromSafeConstant(b.toString()); } public static SafeUri createDipDownloadUri(String dipUUID) { @@ -120,7 +122,7 @@ public static SafeUri createDipFilePreviewUri(String dipFileUUID, boolean conten return UriUtils.fromSafeConstant(b.toString()); } - public static SafeUri createDipFileDownloadUri(String dipFileUUID){ + public static SafeUri createDipFileDownloadUri(String dipFileUUID) { // api/v2/dip-files/{file_uuid}/download StringBuilder b = new StringBuilder(); b.append(RodaConstants.API_REST_V2_DIPFILES).append(URL.encodeQueryString(dipFileUUID)) diff --git a/roda-ui/roda-wui/src/main/resources/config/crosswalks/dissemination/html/key-value.xslt b/roda-ui/roda-wui/src/main/resources/config/crosswalks/dissemination/html/key-value.xslt index ff3ddc64be..cd82e34052 100644 --- a/roda-ui/roda-wui/src/main/resources/config/crosswalks/dissemination/html/key-value.xslt +++ b/roda-ui/roda-wui/src/main/resources/config/crosswalks/dissemination/html/key-value.xslt @@ -3,6 +3,7 @@ xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> +