diff --git a/.github/workflows/ci-browser-security.yml b/.github/workflows/ci-browser-security.yml new file mode 100644 index 000000000..9aa3ccf56 --- /dev/null +++ b/.github/workflows/ci-browser-security.yml @@ -0,0 +1,188 @@ +name: Browser Compatibility and DAST + +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + - cron: '29 2 * * 4' + workflow_dispatch: + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + browser-security: + name: Packaged app · 3 browsers · axe-core · OWASP ZAP + runs-on: ubuntu-latest + timeout-minutes: 75 + permissions: + contents: read # Build and scan the checked-out application. + + services: + mysql: + image: mysql:8.0@sha256:7dcddc01f13bab2f15cde676d44d01f61fc9f99fe7785e86196dfc07d358ae2b + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: pinakes_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1" + --health-interval=10s + --health-timeout=5s + --health-retries=12 + + env: + E2E_BASE_URL: http://localhost:8081 + E2E_ADMIN_EMAIL: admin@pinakes.test + E2E_ADMIN_PASS: Test1234! + E2E_DB_HOST: 127.0.0.1 + E2E_DB_PORT: '3306' + E2E_DB_USER: root + E2E_DB_PASS: root + E2E_DB_NAME: pinakes_test + + steps: + - name: Checkout without persisted credentials + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Install Apache and packaging tools + run: | + sudo apt-get update -q + sudo apt-get install -y apache2 libapache2-mod-php jq rsync unzip zip + sudo a2enmod rewrite headers env + php_module=$(find /etc/apache2/mods-available -maxdepth 1 -name 'php*.load' -printf '%f\n' | sed 's/\.load$//' | sort -V | tail -n1) + if [ -n "$php_module" ]; then sudo a2enmod "$php_module"; fi + sudo a2dissite 000-default || true + + - name: Setup PHP 8.2 tooling + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: '8.2' + extensions: mysqli, pdo_mysql, mbstring, curl, intl, xml, zip, gd + coverage: none + + - name: Setup Node 22 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: '22' + cache: npm + cache-dependency-path: | + package-lock.json + frontend/package-lock.json + + - name: Build the exact release artifact under test + run: | + composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader + npm ci --silent + npm --prefix frontend ci --silent + npm --prefix frontend run build + bash bin/build-release.sh --skip-build + version=$(jq -r .version version.json) + mkdir -p "$RUNNER_TEMP/pinakes-package" + unzip -q "releases/pinakes-v${version}.zip" -d "$RUNNER_TEMP/pinakes-package" + package_root="$RUNNER_TEMP/pinakes-package/pinakes-v${version}" + test -f "$package_root/public/index.php" + echo "PACKAGE_ROOT=$package_root" >> "$GITHUB_ENV" + echo "E2E_INSTALL_ROOT=$package_root" >> "$GITHUB_ENV" + + - name: Install all supported Playwright browser engines + run: npx playwright install chromium firefox webkit --with-deps + + - name: Configure Apache for the packaged application + run: | + path="$PACKAGE_ROOT" + while [ "$path" != / ]; do sudo chmod o+x "$path"; path=$(dirname "$path"); done + sudo chmod 777 "$PACKAGE_ROOT" + sudo chmod -R 777 "$PACKAGE_ROOT/storage" + sudo mkdir -p "$PACKAGE_ROOT/public/uploads" + sudo chmod -R 777 "$PACKAGE_ROOT/public/uploads" + sudo tee /etc/apache2/sites-available/pinakes.conf >/dev/null < + ServerName localhost + DocumentRoot ${PACKAGE_ROOT}/public + SetEnv PINAKES_E2E_BYPASS_RATE_LIMIT 1 + SetEnv PINAKES_E2E_SCRAPER_STUB 1 + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + ErrorLog \${APACHE_LOG_DIR}/pinakes-error.log + CustomLog \${APACHE_LOG_DIR}/pinakes-access.log combined + + EOF + sudo a2ensite pinakes + sudo apachectl configtest + sudo systemctl start apache2 + for attempt in $(seq 1 30); do + curl -sf -o /dev/null http://localhost:8081/installer/ && exit 0 + echo "waiting for packaged installer ($attempt/30)" + sleep 1 + done + exit 1 + + - name: Install and bootstrap the packaged application + env: + PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/package-bootstrap.json + PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report-bootstrap + run: npx playwright test tests/full-test.spec.js --config=tests/playwright.ci.config.js --workers=1 + + - name: Audit installer failures, flakes and skips + if: always() + run: node scripts/ci-audit-playwright-results.js test-results/package-bootstrap.json + + - name: Test WCAG and runtime behavior in Chromium, Firefox and WebKit + env: + PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/cross-browser.json + PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report-cross-browser + run: npx playwright test --config=tests/playwright.cross-browser.config.js --workers=1 + + - name: Audit cross-browser failures, flakes and skips + if: always() + run: node scripts/ci-audit-playwright-results.js test-results/cross-browser.json + + - name: Run OWASP ZAP passive baseline scan + uses: zaproxy/action-baseline@de8ad967d3548d44ef623df22cf95c3b0baf8b25 # v0.15.0 + with: + target: http://localhost:8081 + docker_name: ghcr.io/zaproxy/zaproxy:stable@sha256:781a2bdaea47324e7bab583e2263f21d257b0aee61ed51521a5be45f5f5081ef + cmd_options: -a -m 3 + fail_action: false + allow_issue_writing: false + artifact_name: zap-baseline-${{ github.run_id }} + + - name: Fail on medium or high ZAP alerts + run: | + test -s report_json.json || { echo "ZAP JSON report is missing"; exit 1; } + blocking=$(jq '[.site[]?.alerts[]? | select((.riskcode | tonumber) >= 2)] | length' report_json.json) + if [ "$blocking" -gt 0 ]; then + jq -r '.site[]?.alerts[]? | select((.riskcode | tonumber) >= 2) | "[\(.riskdesc)] \(.alert): \(.desc)"' report_json.json + exit 1 + fi + echo "ZAP found no medium/high passive-scan alerts" + + - name: Upload browser and server diagnostics + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: browser-security-evidence-${{ github.run_id }} + path: | + playwright-report-bootstrap/ + playwright-report-cross-browser/ + test-results/ + report_json.json + report_md.md + report_html.html + /var/log/apache2/pinakes-error.log + /var/log/apache2/pinakes-access.log + if-no-files-found: warn + retention-days: 21 diff --git a/.github/workflows/ci-database-compatibility.yml b/.github/workflows/ci-database-compatibility.yml new file mode 100644 index 000000000..f5c7dd091 --- /dev/null +++ b/.github/workflows/ci-database-compatibility.yml @@ -0,0 +1,144 @@ +name: Database Compatibility + +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + - cron: '43 4 * * 3' + workflow_dispatch: + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + database: + name: ${{ matrix.database.name }} schema and behavior + runs-on: ubuntu-latest + timeout-minutes: 35 + permissions: + contents: read # Read application, schema and test sources. + strategy: + fail-fast: false + matrix: + database: + - name: MySQL 8.0 LTS baseline + image: mysql:8.0@sha256:7dcddc01f13bab2f15cde676d44d01f61fc9f99fe7785e86196dfc07d358ae2b + health_cmd: mysqladmin ping -h 127.0.0.1 -proot --silent + - name: MySQL 8.4 LTS + image: mysql:8.4@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb + health_cmd: mysqladmin ping -h 127.0.0.1 -proot --silent + - name: MariaDB 10.11 LTS + image: mariadb:10.11@sha256:de61fed4a40d3842f3ee09944ba52792156cfd9adf489b2cc670fc6ded28df8d + health_cmd: healthcheck.sh --connect --innodb_initialized + - name: MariaDB 11.4 LTS + image: mariadb:11.4@sha256:67873d30a17f6a9c331f06363b2fa15f38abca415529966d67c84f87f82439fe + health_cmd: healthcheck.sh --connect --innodb_initialized + + services: + database: + image: ${{ matrix.database.image }} + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: pinakes_test + ports: + - 3306:3306 + options: >- + --health-cmd="${{ matrix.database.health_cmd }}" + --health-interval=10s + --health-timeout=5s + --health-retries=20 + + env: + E2E_DB_HOST: 127.0.0.1 + E2E_DB_PORT: '3306' + E2E_DB_USER: root + E2E_DB_PASS: root + E2E_DB_NAME: pinakes_test + DB_HOST: 127.0.0.1 + DB_PORT: '3306' + DB_USER: root + DB_PASS: root + DB_NAME: pinakes_test + CI_STRICT_TESTS: '1' + + steps: + - name: Checkout without persisted credentials + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Setup PHP 8.2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: '8.2' + extensions: mysqli, pdo_mysql, mbstring, curl, intl, xml + coverage: none + + - name: Install locked production dependencies + run: composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader + + - name: Wait for the database over TCP + run: | + for attempt in $(seq 1 45); do + if MYSQL_PWD=root mysql -h 127.0.0.1 -P 3306 -u root -e 'SELECT VERSION()' 2>/dev/null; then + exit 0 + fi + echo "database not ready ($attempt/45)" + sleep 2 + done + exit 1 + + - name: Import the complete install baseline + run: | + MYSQL_PWD=root mysql -h 127.0.0.1 -P 3306 -u root pinakes_test < installer/database/schema.sql + MYSQL_PWD=root mysql -h 127.0.0.1 -P 3306 -u root pinakes_test < installer/database/data_it_IT.sql + MYSQL_PWD=root mysql -h 127.0.0.1 -P 3306 -u root pinakes_test < installer/database/triggers.sql + cat > .env <<'ENVEOF' + DB_HOST=127.0.0.1 + DB_PORT=3306 + DB_USER=root + DB_PASS=root + DB_NAME=pinakes_test + ENVEOF + # data_it_IT.sql provides bundled plugin metadata, but the runtime + # registry is populated by PluginManager. Exercise that real install + # step so self-healing tests cannot silently skip book-club. + # shellcheck disable=SC2016 + php -r ' + require "vendor/autoload.php"; + $db = new mysqli("127.0.0.1", "root", "root", "pinakes_test", 3306); + $db->set_charset("utf8mb4"); + $hooks = new App\Support\HookManager($db); + (new App\Support\PluginManager($db, $hooks))->autoRegisterBundledPlugins(); + ' + plugin_count=$(MYSQL_PWD=root mysql -h 127.0.0.1 -P 3306 -u root -Nse \ + "SELECT COUNT(*) FROM pinakes_test.plugins WHERE name='book-club'") + test "$plugin_count" = 1 + + - name: Exercise all standalone behavioral tests in strict mode + run: bash scripts/ci-run-unit-tests.sh + + - name: Exercise migration and plugin self-healing contracts + run: bash scripts/verify-schema.sh + + - name: Verify schema invariants after all tests + run: | + table_count=$(MYSQL_PWD=root mysql -h 127.0.0.1 -P 3306 -u root -Nse \ + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='pinakes_test'") + trigger_count=$(MYSQL_PWD=root mysql -h 127.0.0.1 -P 3306 -u root -Nse \ + "SELECT COUNT(*) FROM information_schema.triggers WHERE trigger_schema='pinakes_test'") + default_languages=$(MYSQL_PWD=root mysql -h 127.0.0.1 -P 3306 -u root -Nse \ + "SELECT COUNT(*) FROM pinakes_test.languages WHERE is_default=1") + test "$table_count" -ge 40 + test "$trigger_count" -ge 1 + test "$default_languages" = 1 + echo "verified $table_count tables, $trigger_count triggers and one default language" + + - name: Remove temporary database credentials + if: always() + run: rm -f .env diff --git a/.github/workflows/ci-deep-regression.yml b/.github/workflows/ci-deep-regression.yml new file mode 100644 index 000000000..3df25e097 --- /dev/null +++ b/.github/workflows/ci-deep-regression.yml @@ -0,0 +1,358 @@ +name: Deep Regression Gate + +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + - cron: '17 2 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + regression: + name: Browser regression shard ${{ matrix.shard }}/4 + runs-on: ubuntu-latest + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + + services: + mysql: + image: mysql:8.0@sha256:7dcddc01f13bab2f15cde676d44d01f61fc9f99fe7785e86196dfc07d358ae2b + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: pinakes_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + mailpit: + image: axllent/mailpit:v1.21@sha256:81370195cd4a0eab9604d17c2617a7525b0486f9365555253b6c5376c6350f1a + ports: + - 1025:1025 + - 8025:8025 + + env: + E2E_BASE_URL: http://localhost:8081 + APP_URL: http://localhost:8081 + E2E_ADMIN_EMAIL: admin@pinakes.test + E2E_ADMIN_PASS: Test1234! + E2E_DB_HOST: 127.0.0.1 + E2E_DB_PORT: 3306 + E2E_DB_USER: root + E2E_DB_PASS: root + E2E_DB_NAME: pinakes_test + E2E_INSTALL_ROOT: ${{ github.workspace }} + MAILPIT_API: http://127.0.0.1:8025/api/v1 + E2E_PHP_UPLOAD_MAX_BYTES: 8388608 + E2E_PHP_POST_MAX_BYTES: 10485760 + + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Install Apache, PHP module, MySQL client and mail transport + run: | + sudo apt-get update -q + sudo apt-get install -y apache2 libapache2-mod-php default-mysql-client msmtp-mta + sudo a2enmod rewrite headers env + php_module="$(find /etc/apache2/mods-available -maxdepth 1 -name 'php*.load' -printf '%f\n' | sed 's/\.load$//' | sort -V | tail -n1)" + [ -z "${php_module}" ] || sudo a2enmod "${php_module}" + sudo a2dissite 000-default || true + sudo tee /etc/msmtprc >/dev/null <<'EOF' + defaults + account default + host 127.0.0.1 + port 1025 + tls off + from pinakes-ci@localhost + EOF + sudo chmod 644 /etc/msmtprc + while IFS= read -r conf_dir; do + sudo tee "${conf_dir}/99-pinakes-ci.ini" >/dev/null <<'EOF' + sendmail_path = "/usr/bin/msmtp -t" + upload_max_filesize = 8M + post_max_size = 10M + EOF + done < <(find /etc/php -type d -path '*/conf.d' | sort -u) + + - name: Setup PHP 8.2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: '8.2' + extensions: mysqli, mbstring, json, curl, openssl, zip, gd, intl, xml + coverage: none + + - name: Setup Node 22 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: '22' + cache: npm + + - name: Install locked dependencies and Chromium + run: | + composer install --no-interaction --prefer-dist --no-dev --optimize-autoloader + npm ci --silent + npx playwright install chromium --with-deps + + - name: Route legacy MySQL test clients to the service container + run: echo "${GITHUB_WORKSPACE}/scripts/ci-bin" >> "${GITHUB_PATH}" + + - name: Verify test coverage policy + run: npm run test:ci-policy + + - name: Configure Apache on port 8081 + env: + WORKSPACE: ${{ github.workspace }} + run: | + sudo tee /etc/apache2/sites-available/pinakes.conf >/dev/null < + ServerName localhost + DocumentRoot ${WORKSPACE}/public + SetEnv PINAKES_E2E_BYPASS_RATE_LIMIT 1 + SetEnv PINAKES_E2E_SCRAPER_STUB 1 + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + ErrorLog \${APACHE_LOG_DIR}/pinakes-error.log + CustomLog \${APACHE_LOG_DIR}/pinakes-access.log combined + + EOF + sudo a2ensite pinakes + sudo apachectl configtest + + - name: Grant runtime permissions and start Apache + env: + WORKSPACE: ${{ github.workspace }} + run: | + path="${WORKSPACE}" + while [ "${path}" != "/" ]; do + sudo chmod o+x "${path}" + path="$(dirname "${path}")" + done + sudo chmod 777 "${WORKSPACE}" + sudo chmod -R 777 "${WORKSPACE}/storage" + sudo mkdir -p "${WORKSPACE}/public/uploads" + sudo chmod -R 777 "${WORKSPACE}/public/uploads" + sudo systemctl start apache2 + for _ in $(seq 1 30); do + curl -sf -o /dev/null http://localhost:8081/installer/ && exit 0 + sleep 1 + done + sudo cat /var/log/apache2/pinakes-error.log || true + exit 1 + + - name: Bootstrap a complete fresh installation + env: + PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/bootstrap-${{ matrix.shard }}.json + PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report-bootstrap-${{ matrix.shard }} + run: npx playwright test tests/full-test.spec.js --config=tests/playwright.ci.config.js --workers=1 + + - name: Audit bootstrap failures, flakes and suspicious skips + if: always() + env: + RESULT_FILE: test-results/bootstrap-${{ matrix.shard }}.json + run: node scripts/ci-audit-playwright-results.js "$RESULT_FILE" + + - name: Seed deterministic cross-suite fixtures + env: + E2E_RUN_SEED: '1' + E2E_OFFLINE_SEED: '1' + PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/setup-${{ matrix.shard }}.json + PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report-setup-${{ matrix.shard }} + run: | + npx playwright test \ + tests/interop-00-activate-plugins.spec.js \ + tests/archives-seed-50.spec.js \ + tests/seed-catalog.spec.js \ + tests/persistent-seed-crud.spec.js \ + --config=tests/playwright.ci.config.js \ + --workers=1 + # Apache/PHP may recreate runtime subdirectories with its own umask + # while seeding. The later CLI cron tests need the same shared tree. + sudo chmod -R 777 "${GITHUB_WORKSPACE}/storage" + sudo chmod -R 777 "${GITHUB_WORKSPACE}/public/uploads" + # Installer deliberately protects .env from Apache users. CLI-level + # integration harnesses run as the ephemeral GitHub runner and need + # to read the same throwaway CI credentials. + sudo chmod 640 "${GITHUB_WORKSPACE}/.env" + sudo chgrp "$(id -gn)" "${GITHUB_WORKSPACE}/.env" + + - name: Audit setup fixtures + if: always() + env: + RESULT_FILE: test-results/setup-${{ matrix.shard }}.json + run: node scripts/ci-audit-playwright-results.js "$RESULT_FILE" + + - name: Run every deep-regression spec in shard ${{ matrix.shard }} + id: deep-regression + env: + SHARD: ${{ matrix.shard }} + PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/shard-${{ matrix.shard }}.json + PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report-shard-${{ matrix.shard }} + run: | + mapfile -t spec_files < <(node scripts/ci-playwright-policy.js shard "$SHARD" 4) + [ "${#spec_files[@]}" -gt 0 ] || { echo "No specs selected for shard"; exit 1; } + printf 'Running %s deep specs in shard %s/4\n' "${#spec_files[@]}" "$SHARD" + npx playwright test "${spec_files[@]}" \ + --config=tests/playwright.ci.config.js \ + --workers=1 + + - name: Audit failures, flakes and suspicious skips + if: always() && steps.deep-regression.outcome != 'skipped' + env: + RESULT_FILE: test-results/shard-${{ matrix.shard }}.json + run: node scripts/ci-audit-playwright-results.js "$RESULT_FILE" + + - name: Upload browser evidence + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + if: always() + with: + name: deep-regression-shard-${{ matrix.shard }}-${{ github.run_id }} + path: | + playwright-report-shard-${{ matrix.shard }}/ + playwright-report-bootstrap-${{ matrix.shard }}/ + playwright-report-setup-${{ matrix.shard }}/ + test-results/ + storage/logs/app.log + /var/log/apache2/pinakes-error.log + /var/log/apache2/pinakes-access.log + retention-days: 14 + if-no-files-found: warn + + installer-locales: + name: Fresh installer (${{ matrix.locale }}) + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + locale: [it_IT, en_US, de_DE, fr_FR, da_DK] + + services: + mysql: + image: mysql:8.0@sha256:7dcddc01f13bab2f15cde676d44d01f61fc9f99fe7785e86196dfc07d358ae2b + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: pinakes_locale_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + env: + E2E_BASE_URL: http://localhost:8081 + E2E_ADMIN_EMAIL: locale-admin@pinakes.test + E2E_ADMIN_PASS: Test1234! + E2E_DB_HOST: 127.0.0.1 + E2E_DB_PORT: 3306 + E2E_DB_USER: root + E2E_DB_PASS: root + E2E_DB_NAME: pinakes_locale_test + E2E_INSTALL_ROOT: ${{ github.workspace }} + E2E_RUN_INSTALLER_SPECS: '1' + E2E_LOCALE: ${{ matrix.locale }} + + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Install Apache and PHP module + run: | + sudo apt-get update -q + sudo apt-get install -y apache2 libapache2-mod-php default-mysql-client + sudo a2enmod rewrite headers env + php_module="$(find /etc/apache2/mods-available -maxdepth 1 -name 'php*.load' -printf '%f\n' | sed 's/\.load$//' | sort -V | tail -n1)" + [ -z "${php_module}" ] || sudo a2enmod "${php_module}" + sudo a2dissite 000-default || true + - name: Setup PHP and Node + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: '8.2' + extensions: mysqli, mbstring, curl, zip, gd, intl, xml + coverage: none + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: '22' + cache: npm + - name: Install locked dependencies and Chromium + run: | + composer install --no-interaction --prefer-dist --no-dev --optimize-autoloader + npm ci --silent + npx playwright install chromium --with-deps + - name: Configure and start isolated application + env: + WORKSPACE: ${{ github.workspace }} + run: | + sudo tee /etc/apache2/sites-available/pinakes.conf >/dev/null < + ServerName localhost + DocumentRoot ${WORKSPACE}/public + SetEnv PINAKES_E2E_BYPASS_RATE_LIMIT 1 + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + ErrorLog \${APACHE_LOG_DIR}/pinakes-error.log + + EOF + sudo a2ensite pinakes + path="${WORKSPACE}" + while [ "${path}" != "/" ]; do sudo chmod o+x "${path}"; path="$(dirname "${path}")"; done + sudo chmod 777 "${WORKSPACE}" + sudo chmod -R 777 "${WORKSPACE}/storage" + sudo mkdir -p "${WORKSPACE}/public/uploads" + sudo chmod -R 777 "${WORKSPACE}/public/uploads" + sudo apachectl configtest + sudo systemctl start apache2 + for _ in $(seq 1 30); do + curl -sf -o /dev/null http://localhost:8081/installer/ && exit 0 + sleep 1 + done + exit 1 + - name: Install and verify locale ${{ matrix.locale }} + env: + PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/installer-${{ matrix.locale }}.json + PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report-installer-${{ matrix.locale }} + run: | + npx playwright test tests/multilang-install-i18n.spec.js \ + --config=tests/playwright.ci.config.js \ + --workers=1 + - name: Audit installer result + if: always() + env: + RESULT_FILE: test-results/installer-${{ matrix.locale }}.json + run: node scripts/ci-audit-playwright-results.js "$RESULT_FILE" + - name: Upload installer evidence + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + if: always() + with: + name: installer-${{ matrix.locale }}-${{ github.run_id }} + path: | + playwright-report-installer-${{ matrix.locale }}/ + test-results/ + /var/log/apache2/pinakes-error.log + retention-days: 14 + if-no-files-found: warn diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index 2b039d0b9..6fedf9970 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -18,11 +18,11 @@ jobs: e2e: name: Full E2E suite (Playwright · Apache · MySQL) runs-on: ubuntu-latest - timeout-minutes: 40 + timeout-minutes: 60 services: mysql: - image: mysql:8.0 + image: mysql:8.0@sha256:7dcddc01f13bab2f15cde676d44d01f61fc9f99fe7785e86196dfc07d358ae2b env: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: pinakes_test @@ -46,7 +46,9 @@ jobs: E2E_INSTALL_ROOT: ${{ github.workspace }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false # ── Apache2 + mod_php ─────────────────────────────────────────────────── # Install Apache before setup-php adds external package sources; the app @@ -64,12 +66,11 @@ jobs: # ── PHP 8.2 CLI for Composer and tooling ──────────────────────────────── - name: Setup PHP 8.2 - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: '8.2' extensions: mysqli, mbstring, json, curl, openssl, zip, gd, intl, xml coverage: none - github-token: '' - name: Configure virtual host on port 8081 env: @@ -98,7 +99,7 @@ jobs: # ── PHP dependencies ──────────────────────────────────────────────────── - name: Cache Composer packages - uses: actions/cache@v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: vendor key: composer-${{ hashFiles('composer.lock') }} @@ -108,13 +109,17 @@ jobs: run: composer install --no-interaction --prefer-dist --no-dev --optimize-autoloader # ── Node / Playwright ──────────────────────────────────────────────────── - - name: Setup Node 20 - uses: actions/setup-node@v4 + - name: Setup Node 22 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: - node-version: '20' + node-version: '22' + cache: npm - name: Install Node dependencies - run: npm install --silent + run: npm ci --silent + + - name: Verify CI covers every Playwright spec + run: npm run test:ci-policy - name: Install Playwright Chromium run: npx playwright install chromium --with-deps @@ -294,57 +299,52 @@ jobs: --config=tests/playwright.config.js \ --workers=1 - # ── Orphan-spec backfill: tests that were authored against a fresh - # install but never wired into the CI workflow. Adding them as - # `continue-on-error: true` so we get the signal from a clean - # fixture without gating the merge on tests that may have flake- - # or design-level issues unrelated to the merging PR. Once the - # individual specs prove stable (see #144 follow-up triage), drop - # the continue-on-error and promote to a numbered step. ────────── - - name: "[orphan/1] book-form-comprehensive.spec.js" - continue-on-error: true + # Activate linked-data plugins before suites whose routes are registered + # only for active plugins. A missing route is now a hard failure. + - name: "Interop plugin preflight" + run: | + npx playwright test tests/interop-00-activate-plugins.spec.js \ + --config=tests/playwright.config.js \ + --workers=1 + + # Formerly non-blocking backfill. These are release gates now: no test + # failure is allowed to leave the workflow green. + - name: "[regression/1] book-form-comprehensive.spec.js" run: | npx playwright test tests/book-form-comprehensive.spec.js \ --config=tests/playwright.config.js \ --workers=1 - - name: "[orphan/2] code-quality.spec.js" - continue-on-error: true + - name: "[regression/2] code-quality.spec.js" run: | npx playwright test tests/code-quality.spec.js \ --config=tests/playwright.config.js \ --workers=1 - - name: "[orphan/3] genre-merge-rearrange.spec.js" - continue-on-error: true + - name: "[regression/3] genre-merge-rearrange.spec.js" run: | npx playwright test tests/genre-merge-rearrange.spec.js \ --config=tests/playwright.config.js \ --workers=1 - - name: "[orphan/4] loan-reservation.spec.js" - continue-on-error: true + - name: "[regression/4] loan-reservation.spec.js" run: | npx playwright test tests/loan-reservation.spec.js \ --config=tests/playwright.config.js \ --workers=1 - - name: "[orphan/5] multisource-scraping.spec.js" - continue-on-error: true + - name: "[regression/5] multisource-scraping.spec.js" run: | npx playwright test tests/multisource-scraping.spec.js \ --config=tests/playwright.config.js \ --workers=1 - - name: "[orphan/6] security-hardening.spec.js" - continue-on-error: true + - name: "[regression/6] security-hardening.spec.js" run: | npx playwright test tests/security-hardening.spec.js \ --config=tests/playwright.config.js \ --workers=1 - - name: "[orphan/7] viaf-authority.spec.js" - continue-on-error: true + - name: "[regression/7] viaf-authority.spec.js" run: | npx playwright test tests/viaf-authority.spec.js \ --config=tests/playwright.config.js \ --workers=1 - - name: "[orphan/8] interop-specific.spec.js" - continue-on-error: true + - name: "[regression/8] interop-specific.spec.js" run: | npx playwright test tests/interop-specific.spec.js \ --config=tests/playwright.config.js \ @@ -357,7 +357,7 @@ jobs: # ── Artifacts on failure ───────────────────────────────────────────────── - name: Upload Playwright report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: failure() with: name: playwright-report-${{ github.run_id }} @@ -367,7 +367,7 @@ jobs: retention-days: 14 - name: Upload Apache error log - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: failure() with: name: apache-error-log-${{ github.run_id }} diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index a4b5c81ee..0a48a595d 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -12,10 +12,20 @@ on: - '**.js' - 'locale/**' - 'composer.json' + - 'composer.lock' - 'package.json' + - 'package-lock.json' + - 'frontend/package.json' + - 'frontend/package-lock.json' + - 'frontend/**/*.js' + - 'frontend/**/*.css' + - 'public/assets/**' + - 'scripts/ci-*' + - 'tests/*.test.sh' + - 'tests/ci-playwright-policy.json' - 'installer/database/schema.sql' - 'version.json' - - '.github/workflows/ci-quality.yml' + - '.github/workflows/**' pull_request: branches: [main] workflow_dispatch: @@ -38,7 +48,7 @@ jobs: # updater.md, so CI provides a real database instead of skipping them. services: mysql: - image: mysql:8.0 + image: mysql:8.0@sha256:7dcddc01f13bab2f15cde676d44d01f61fc9f99fe7785e86196dfc07d358ae2b env: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: pinakes_test @@ -51,21 +61,48 @@ jobs: --health-retries=5 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: '8.2' extensions: mysqli, pdo_mysql coverage: none - github-token: '' + + - name: Install pinned PHPStan with verified Composer transport + run: | + PHPSTAN_HOME=$(mktemp -d "${RUNNER_TEMP}/pinakes-phpstan.XXXXXX") + installed=0 + for attempt in 1 2 3; do + if COMPOSER_HOME="$PHPSTAN_HOME" composer global require \ + --no-interaction --prefer-dist --no-progress \ + phpstan/phpstan:2.1.56; then + installed=1 + break + fi + echo "PHPStan install attempt ${attempt}/3 failed" + sleep $((attempt * 2)) + done + [ "$installed" -eq 1 ] || { echo "PHPStan installation failed after 3 attempts"; exit 1; } + PHPSTAN_BIN="${PHPSTAN_HOME}/vendor/bin/phpstan" + [ -x "$PHPSTAN_BIN" ] || { echo "PHPStan executable missing after install"; exit 1; } + "$PHPSTAN_BIN" --version + echo "PHPSTAN_BIN=$PHPSTAN_BIN" >> "$GITHUB_ENV" - name: Setup Node - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: '22' + - name: Setup Go for workflow validation + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: '1.25.x' + cache: false + # ── Dependency CVE scanning ─────────────────────────────────────────── - name: composer audit (known CVEs) run: | @@ -74,52 +111,34 @@ jobs: - name: npm audit (known CVEs) run: | - npm ci --silent 2>/dev/null || npm install --silent + npm ci --silent npm audit --audit-level=high 2>&1 || { echo "⚠ npm audit: high/critical vulnerabilities found"; exit 1; } - continue-on-error: true # Warn, don't block — js devDependencies may have unfixed advisories + + - name: Root vendor assets are reproducible + run: | + git diff --exit-code -- public/assets/vendor/sortablejs + test -z "$(git ls-files --others --exclude-standard -- public/assets/vendor/sortablejs)" || { + echo "Generated vendor assets contain untracked files" + git ls-files --others --exclude-standard -- public/assets/vendor/sortablejs + exit 1 + } + + - name: CI and Playwright coverage policy + run: npm run test:ci-policy + + - name: Release source policy regression tests + run: bash tests/release-source-policy.test.sh + + - name: Validate every active GitHub Actions workflow + run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 .github/workflows/*.yml # ── Translation completeness ────────────────────────────────────────── - - name: Translation key parity (en_US ↔ de_DE, placeholders en/de/it) + - name: Translation and route parity (all supported locales) + run: python3 scripts/ci-check-locales.py + + - name: PHPStan (level from phpstan.neon) run: | - FAILED=0 - # 1. Exact key-set parity for non-Italian translations only. - # it_IT is intentionally sparse: __() falls back to the key itself - # (keys are native Italian text), so it_IT.json only needs overrides. - for PAIR in "en_US:de_DE"; do - A=${PAIR%%:*}; B=${PAIR##*:} - MISSING=$(comm -23 <(jq -r 'keys[]' locale/${A}.json | sort) <(jq -r 'keys[]' locale/${B}.json | sort)) - EXTRA=$(comm -13 <(jq -r 'keys[]' locale/${A}.json | sort) <(jq -r 'keys[]' locale/${B}.json | sort)) - if [ -n "$MISSING" ]; then - echo "✗ ${B}.json manca di chiavi presenti in ${A}.json:" - echo "$MISSING" | sed 's/^/ /' - FAILED=1 - fi - if [ -n "$EXTRA" ]; then - echo "✗ ${B}.json ha chiavi extra non presenti in ${A}.json:" - echo "$EXTRA" | sed 's/^/ /' - FAILED=1 - fi - done - # 2. Placeholder parity: %s / %d must match count between en_US, de_DE and it_IT - python3 - <<'PYEOF' - import json, re, sys - en = json.load(open('locale/en_US.json')) - de = json.load(open('locale/de_DE.json')) - it = json.load(open('locale/it_IT.json')) - ph = re.compile(r'%(?:\d+\$)?[sd]') - failed = 0 - for locale_name, data in [('de_DE', de), ('it_IT', it)]: - for k in en: - if k not in data: - continue - ep = sorted(ph.findall(str(en[k]))) - lp = sorted(ph.findall(str(data[k]))) - if ep != lp: - print(f'✗ Placeholder mismatch for key "{k[:80]}": en={ep} {locale_name}={lp}') - failed = 1 - sys.exit(failed) - PYEOF - [ "$FAILED" -eq 0 ] && echo "✓ Parità chiavi e placeholder confermata" || exit 1 + "$PHPSTAN_BIN" analyse --no-progress --memory-limit=512M # ── Route key integrity ─────────────────────────────────────────────── - name: Route key integrity (route_path() keys exist in routes_it_IT.json) @@ -160,18 +179,19 @@ jobs: FAILED=0 for file in storage/plugins/*/*.php; do [ -f "$file" ] || continue + plugin_file="$(basename "$(dirname "$file")")/$(basename "$file")" if grep -q 'CREATE TABLE' "$file"; then if ! grep -q 'ensureSchema()' "$file"; then - echo " ✗ $(basename $(dirname $file))/$(basename $file): CREATE TABLE senza ensureSchema()" + echo " ✗ ${plugin_file}: CREATE TABLE senza ensureSchema()" FAILED=1 - elif ! awk '/function onActivate/,/^[[:space:]]*}/' "$file" | grep -q 'ensureSchema'; then - echo " ✗ $(basename $(dirname $file))/$(basename $file): ensureSchema() non in onActivate()" + elif ! awk '/function onActivate/,/^ }$/' "$file" | grep -q 'ensureSchema'; then + echo " ✗ ${plugin_file}: ensureSchema() non in onActivate()" FAILED=1 - elif ! awk '/function onInstall/,/^[[:space:]]*}/' "$file" | grep -q 'ensureSchema'; then - echo " ✗ $(basename $(dirname $file))/$(basename $file): ensureSchema() non in onInstall()" + elif ! awk '/function onInstall/,/^ }$/' "$file" | grep -q 'ensureSchema'; then + echo " ✗ ${plugin_file}: ensureSchema() non in onInstall()" FAILED=1 else - echo " ✓ $(basename $(dirname $file))/$(basename $file)" + echo " ✓ ${plugin_file}" fi fi done @@ -179,28 +199,14 @@ jobs: # ── Soft-delete guard ───────────────────────────────────────────────── - name: Soft-delete guard (libri queries must include deleted_at IS NULL) - run: | - VIOLATIONS=0 - while IFS= read -r file; do - if grep -qiE 'FROM[[:space:]]+`?libri`?[[:space:],)]' "$file" 2>/dev/null; then - if ! grep -qiE 'deleted_at[[:space:]]+IS[[:space:]]+NULL' "$file"; then - echo " ⚠ $file: query FROM libri senza deleted_at IS NULL" - VIOLATIONS=$((VIOLATIONS + 1)) - fi - fi - done < <(find app/ -name "*.php" -not -path "*/vendor/*") - if [ "$VIOLATIONS" -gt 0 ]; then - echo "⚠ $VIOLATIONS file potrebbero mancare del soft-delete guard" - echo " Verificare manualmente se le query sono intentenzionali (query admin, conteggi, ecc.)" - else - echo "✓ Soft-delete guard presente in tutti i file con query su libri" - fi + run: python3 scripts/ci-check-soft-delete.py app storage/plugins installer # ── Autoloader safety ───────────────────────────────────────────────── - name: Autoloader phpstan-free run: | if [ -f vendor/composer/autoload_static.php ]; then - COUNT=$(grep -c "phpstan" vendor/composer/autoload_static.php || echo 0) + COUNT=$(grep -c "phpstan" vendor/composer/autoload_static.php || true) + COUNT=${COUNT:-0} if [ "$COUNT" -gt 0 ]; then echo "✗ autoload_static.php contiene $COUNT riferimenti a phpstan (run composer install --no-dev)" exit 1 @@ -216,9 +222,12 @@ jobs: TARGET=$(php -r "echo json_decode(file_get_contents('version.json'))->version;") FAILED=0 for f in installer/database/migrations/migrate_*.sql; do - V=$(basename "$f" | sed 's/migrate_//;s/\.sql//') - if ! php -r "exit(version_compare('$V','$TARGET','<=') ? 0 : 1);"; then - echo " ✗ $(basename $f): $V > $TARGET (sarebbe ignorata dall'updater)" + migration_file="$(basename "$f")" + V="${migration_file#migrate_}" + V="${V%.sql}" + if ! MIGRATION_VERSION="$V" RELEASE_VERSION="$TARGET" php -r \ + 'exit(version_compare(getenv("MIGRATION_VERSION"), getenv("RELEASE_VERSION"), "<=") ? 0 : 1);'; then + echo " ✗ ${migration_file}: $V > $TARGET (sarebbe ignorata dall'updater)" FAILED=1 fi done @@ -238,24 +247,44 @@ jobs: DB_NAME=pinakes_test ENVEOF # Wait until MySQL answers, then import the base schema + triggers. - for i in $(seq 1 30); do + for _ in $(seq 1 30); do mysql -h 127.0.0.1 -u root -proot -e "SELECT 1" >/dev/null 2>&1 && break sleep 2 done mysql -h 127.0.0.1 -u root -proot pinakes_test < installer/database/schema.sql + # Exercise the unit suite against the same baseline as a completed + # Italian install. The schema alone contains no default language or + # bundled-plugin registrations, which used to turn real regressions + # into silent SKIP results. + mysql -h 127.0.0.1 -u root -proot pinakes_test < installer/database/data_it_IT.sql # Copy-occupancy triggers live in a separate file (DELIMITER-based); the # mysql CLI handles DELIMITER natively. loan-edge-cases relies on them. mysql -h 127.0.0.1 -u root -proot pinakes_test < installer/database/triggers.sql + # PHP variables must not expand in Bash. + # shellcheck disable=SC2016 + php -r ' + require "vendor/autoload.php"; + $db = new mysqli("127.0.0.1", "root", "root", "pinakes_test", 3306); + $db->set_charset("utf8mb4"); + $hooks = new App\Support\HookManager($db); + (new App\Support\PluginManager($db, $hooks))->autoRegisterBundledPlugins(); + ' + default_languages="$(mysql -h 127.0.0.1 -u root -proot -Nse \ + 'SELECT COUNT(*) FROM pinakes_test.languages WHERE is_default=1')" + book_club_plugins="$(mysql -h 127.0.0.1 -u root -proot -Nse \ + 'SELECT COUNT(*) FROM pinakes_test.plugins WHERE name=0x626f6f6b2d636c7562')" + test "${default_languages}" = "1" + test "${book_club_plugins}" = "1" - name: PHP unit tests (standalone .unit.php) - run: | - FAILED=0 - shopt -s nullglob - for t in tests/*.unit.php; do - echo "── $t" - php "$t" || FAILED=1 - done - [ "$FAILED" -eq 0 ] && echo "✓ Tutti gli unit test PHP passati" || { echo "✗ Unit test PHP falliti"; exit 1; } + env: + CI_STRICT_TESTS: '1' + run: bash scripts/ci-run-unit-tests.sh + + - name: Schema and migration behavioral gate (strict no-skip mode) + env: + CI_STRICT_TESTS: '1' + run: bash scripts/verify-schema.sh - name: Shell test — bin/setup-permissions.sh run: bash tests/setup-permissions.test.sh @@ -263,3 +292,65 @@ jobs: - name: Remove CI .env if: always() run: rm -f .env + + php-compatibility: + name: PHP ${{ matrix.php }} compatibility + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3', '8.4', '8.5'] + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: ${{ matrix.php }} + extensions: mysqli, pdo_mysql, mbstring, curl, intl, xml + coverage: none + - name: Validate and install locked dependencies + run: | + composer validate --strict + composer install --no-interaction --prefer-dist --no-dev --optimize-autoloader + composer check-platform-reqs --no-dev + - name: PHP syntax check (application, installer, plugins, tests) + run: | + find app installer storage/plugins tests -type f -name '*.php' -print0 \ + | xargs -0 -n1 php -l + + frontend: + name: Frontend audit, lint and reproducible build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Setup Node 22 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: '22' + cache: npm + cache-dependency-path: | + package-lock.json + frontend/package-lock.json + - name: Install and audit locked dependencies + run: | + npm ci --silent + npm audit --audit-level=high + npm --prefix frontend ci --silent + npm --prefix frontend audit --audit-level=high + - name: Lint and production build + run: | + npm --prefix frontend run lint + npm --prefix frontend run build + - name: Verify generated assets are committed and reproducible + run: | + git diff --exit-code -- public/assets + untracked_assets="$(git ls-files --others --exclude-standard -- public/assets)" + if [ -n "${untracked_assets}" ]; then + echo "Generated assets are untracked:" + echo "${untracked_assets}" + exit 1 + fi diff --git a/.github/workflows/ci-security-supply-chain.yml b/.github/workflows/ci-security-supply-chain.yml new file mode 100644 index 000000000..c8f644939 --- /dev/null +++ b/.github/workflows/ci-security-supply-chain.yml @@ -0,0 +1,197 @@ +name: Security and Supply Chain + +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + - cron: '17 3 * * 2' + workflow_dispatch: + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + workflow-security: + name: Workflow, YAML and shell security + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read # Read workflow and script sources. + steps: + - name: Checkout without persisted credentials + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Audit GitHub Actions with zizmor + uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 + with: + advanced-security: false + persona: pedantic + + - name: Setup Go for actionlint + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: '1.25.x' + cache: false + + - name: Validate workflow semantics with actionlint + run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 .github/workflows/*.yml + + - name: Validate YAML structure with yamllint + run: | + python3 -m pip install --disable-pip-version-check --no-input yamllint==1.37.1 + yamllint .github .yamllint.yml + + - name: Analyze maintained shell scripts with ShellCheck + run: | + mapfile -t shell_scripts < <(git ls-files '*.sh') + test "${#shell_scripts[@]}" -gt 0 + shellcheck --severity=warning -x "${shell_scripts[@]}" + + dependency-review: + name: Dependency diff policy + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read # Compare dependency manifests in the pull request. + steps: + - name: Reject vulnerable or forbidden new dependencies + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + fail-on-severity: high + fail-on-scopes: runtime + vulnerability-check: true + license-check: true + deny-licenses: AGPL-1.0-only, AGPL-1.0-or-later, BUSL-1.1, SSPL-1.0 + show-openssf-scorecard: true + + repository-security: + name: Secrets, vulnerabilities and misconfigurations + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + contents: read # Scan the repository and complete Git history. + steps: + - name: Checkout complete history without persisted credentials + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Scan complete Git history with Gitleaks + uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_ENABLE_COMMENTS: 'false' + GITLEAKS_ENABLE_UPLOAD_ARTIFACT: 'false' + + - name: Scan dependencies, secrets and IaC with Trivy + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: fs + scan-ref: . + scanners: vuln,secret,misconfig + severity: HIGH,CRITICAL + ignore-unfixed: false + exit-code: '1' + format: table + skip-dirs: vendor,node_modules,frontend/node_modules,public/assets/tinymce + version: v0.73.0 + + release-artifact: + name: Reproducible release, SBOM and archive audit + runs-on: ubuntu-latest + timeout-minutes: 35 + permissions: + contents: read # Build the release from checked-out sources. + steps: + - name: Checkout without persisted credentials + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Setup PHP 8.2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: '8.2' + extensions: mysqli, pdo_mysql, mbstring, curl, intl, xml, zip + coverage: none + + - name: Setup Node 22 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: '22' + cache: npm + cache-dependency-path: | + package-lock.json + frontend/package-lock.json + + - name: Install locked production inputs + run: | + sudo apt-get update -q + sudo apt-get install -y jq rsync unzip zip + composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader + npm ci --silent + npm --prefix frontend ci --silent + npm --prefix frontend run build + + - name: Verify generated assets match the commit + run: git diff --exit-code -- public/assets + + - name: Build the release twice and require byte-for-byte reproducibility + run: | + bash bin/build-release.sh --skip-build + version=$(jq -r .version version.json) + first_hash=$(sha256sum "releases/pinakes-v${version}.zip" | cut -d' ' -f1) + bash bin/build-release.sh --skip-build + second_hash=$(sha256sum "releases/pinakes-v${version}.zip" | cut -d' ' -f1) + test "$first_hash" = "$second_hash" || { + echo "Release is not reproducible: $first_hash != $second_hash" + exit 1 + } + + - name: Verify the exact distributable ZIP + run: | + version=$(jq -r .version version.json) + bash scripts/ci-verify-release.sh "releases/pinakes-v${version}.zip" + mkdir release-under-test + unzip -q "releases/pinakes-v${version}.zip" -d release-under-test + + - name: Scan packaged release with Trivy + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: fs + scan-ref: release-under-test + scanners: vuln,secret,misconfig + severity: HIGH,CRITICAL + ignore-unfixed: false + exit-code: '1' + format: table + version: v0.73.0 + + - name: Generate SPDX JSON SBOM for the packaged release + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + path: release-under-test + format: spdx-json + output-file: releases/pinakes.spdx.json + upload-artifact: false + + - name: Upload verified release evidence + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: verified-release-${{ github.sha }} + path: | + releases/*.zip + releases/*.sha256 + releases/*.spdx.json + releases/RELEASE_NOTES-*.md + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/ci-upgrade-smoke.yml b/.github/workflows/ci-upgrade-smoke.yml index 2dad9e449..080987e35 100644 --- a/.github/workflows/ci-upgrade-smoke.yml +++ b/.github/workflows/ci-upgrade-smoke.yml @@ -22,19 +22,6 @@ on: - '.github/workflows/ci-upgrade-smoke.yml' pull_request: branches: [main] - paths: - - 'installer/database/**' - - 'installer/classes/Installer.php' - - 'app/Models/AuthorRepository.php' - - 'app/Support/ContributorBackfill.php' - - 'app/Support/ContributorSync.php' - - 'app/Support/SearchIndexBuilder.php' - - 'app/Support/Updater.php' - - 'scripts/list-source-expectations.php' - - 'composer.json' - - 'composer.lock' - - 'version.json' - - '.github/workflows/ci-upgrade-smoke.yml' workflow_dispatch: permissions: @@ -51,7 +38,7 @@ jobs: services: mysql: - image: mysql:8.0 + image: mysql:8.0@sha256:7dcddc01f13bab2f15cde676d44d01f61fc9f99fe7785e86196dfc07d358ae2b env: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: pinakes_upgrade @@ -64,15 +51,16 @@ jobs: --health-retries=5 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: '8.2' extensions: mysqli, curl, zip, mbstring coverage: none - github-token: '' - name: Install PHP dependencies run: composer install --no-interaction --prefer-dist --no-progress --no-scripts @@ -84,47 +72,71 @@ jobs: - name: Resolve latest release id: release env: + GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} run: | - HTTP_STATUS=$(curl -s -o /tmp/release.json -w "%{http_code}" \ - "https://api.github.com/repos/${GH_REPO}/releases/latest") - if [ "$HTTP_STATUS" = "404" ]; then + if gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "repos/${GH_REPO}/releases/latest" >/tmp/release.json 2>/tmp/release.err; then + : + elif grep -q 'HTTP 404' /tmp/release.err; then echo "skip=true" >> "$GITHUB_OUTPUT" - echo "No releases found (HTTP 404) — skipping upgrade path (fresh install test only)" + echo "No releases found — skipping upgrade path (fresh install test only)" exit 0 - elif [ "$HTTP_STATUS" != "200" ]; then - echo "GitHub API returned HTTP $HTTP_STATUS — aborting" + else + cat /tmp/release.err + echo "GitHub release API failed — aborting" exit 1 fi TAG=$(python3 -c "import json; d=json.load(open('/tmp/release.json')); print(d.get('tag_name',''))") - ASSET=$(python3 -c "import json; d=json.load(open('/tmp/release.json')); assets=[a['browser_download_url'] for a in d.get('assets',[]) if a['name'].endswith('.zip')]; print(assets[0] if assets else '')") - if [ -z "$TAG" ] || [ -z "$ASSET" ]; then - echo "skip=true" >> "$GITHUB_OUTPUT" - echo "No ZIP asset found in latest release — skipping upgrade path" - exit 0 + ASSET_ID=$(python3 -c "import json; d=json.load(open('/tmp/release.json')); a=[a for a in d.get('assets',[]) if a.get('state') == 'uploaded' and a['name'].endswith('.zip')]; print(a[0]['id'] if len(a) == 1 else '')") + ASSET_NAME=$(python3 -c "import json; d=json.load(open('/tmp/release.json')); a=[a for a in d.get('assets',[]) if a.get('state') == 'uploaded' and a['name'].endswith('.zip')]; print(a[0]['name'] if len(a) == 1 else '')") + CHECKSUM_ID=$(python3 -c "import json; d=json.load(open('/tmp/release.json')); a=[a for a in d.get('assets',[]) if a.get('state') == 'uploaded' and a['name'].endswith('.zip.sha256')]; print(a[0]['id'] if len(a) == 1 else '')") + CHECKSUM_NAME=$(python3 -c "import json; d=json.load(open('/tmp/release.json')); a=[a for a in d.get('assets',[]) if a.get('state') == 'uploaded' and a['name'].endswith('.zip.sha256')]; print(a[0]['name'] if len(a) == 1 else '')") + if [ -z "$TAG" ] || [ -z "$ASSET_ID" ] || [ -z "$ASSET_NAME" ] || [ -z "$CHECKSUM_ID" ] || [ -z "$CHECKSUM_NAME" ]; then + echo "Latest release must provide exactly one ZIP and one ZIP.sha256 asset" + exit 1 fi { echo "tag=$TAG" - echo "asset=$ASSET" + echo "asset_id=$ASSET_ID" + echo "asset_name=$ASSET_NAME" + echo "checksum_id=$CHECKSUM_ID" + echo "checksum_name=$CHECKSUM_NAME" echo "skip=false" } >> "$GITHUB_OUTPUT" - echo "Latest release: $TAG ($ASSET)" + echo "Latest release: $TAG ($ASSET_NAME; checksum verified before use)" # ── Step 2: Install released schema ─────────────────────────────────── - name: Install released schema (baseline) + id: baseline if: steps.release.outputs.skip == 'false' env: - ASSET_URL: ${{ steps.release.outputs.asset }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + ASSET_ID: ${{ steps.release.outputs.asset_id }} + ARCHIVE_NAME: ${{ steps.release.outputs.asset_name }} + CHECKSUM_ID: ${{ steps.release.outputs.checksum_id }} + CHECKSUM_NAME: ${{ steps.release.outputs.checksum_name }} RELEASE_TAG: ${{ steps.release.outputs.tag }} run: | - curl -sfL -o /tmp/release.zip "$ASSET_URL" - mkdir -p /tmp/pinakes-release - unzip -q /tmp/release.zip -d /tmp/pinakes-release - SCHEMA=$(find /tmp/pinakes-release -name "schema.sql" -path "*/database/*" | head -1) + RELEASE_DIR=$(mktemp -d "${RUNNER_TEMP}/pinakes-release.XXXXXX") + gh api -H "Accept: application/octet-stream" \ + "repos/${GH_REPO}/releases/assets/${ASSET_ID}" \ + > "${RELEASE_DIR}/${ARCHIVE_NAME}" + gh api -H "Accept: application/octet-stream" \ + "repos/${GH_REPO}/releases/assets/${CHECKSUM_ID}" \ + > "${RELEASE_DIR}/${CHECKSUM_NAME}" + (cd "$RELEASE_DIR" && sha256sum -c "$CHECKSUM_NAME") + mkdir "${RELEASE_DIR}/extracted" + unzip -q "${RELEASE_DIR}/${ARCHIVE_NAME}" -d "${RELEASE_DIR}/extracted" + SCHEMA=$(find "${RELEASE_DIR}/extracted" -name "schema.sql" -path "*/database/*" | head -1) if [ -z "$SCHEMA" ]; then echo "schema.sql not found in release ZIP — aborting" exit 1 fi + echo "release_dir=${RELEASE_DIR}/extracted" >> "$GITHUB_OUTPUT" echo "Using schema: $SCHEMA (from $RELEASE_TAG)" mysql -h 127.0.0.1 -u root -proot pinakes_upgrade < "$SCHEMA" echo "✓ Base schema installed from $RELEASE_TAG" @@ -138,8 +150,10 @@ jobs: # (pre-guard era) produce when re-run on an already-migrated schema. - name: Apply released migrations if: steps.release.outputs.skip == 'false' + env: + RELEASE_DIR: ${{ steps.baseline.outputs.release_dir }} run: | - MIGRATIONS=$(find /tmp/pinakes-release -type d -name "migrations" | head -1) + MIGRATIONS=$(find "$RELEASE_DIR" -type d -name "migrations" | head -1) if [ -z "$MIGRATIONS" ]; then echo "No migrations dir in release ZIP — skipping" exit 0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index fdaccd767..f2254aaa7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -10,29 +10,37 @@ on: - cron: '0 6 * * 1' workflow_dispatch: +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: analyze: name: Analyze (javascript-typescript) runs-on: ubuntu-latest permissions: - security-events: write - actions: read - contents: read + security-events: write # Upload CodeQL SARIF results. + actions: read # Read workflow metadata for private-repository analysis. + contents: read # Check out and analyze repository sources. steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 with: languages: javascript-typescript queries: security-and-quality config-file: ./.github/codeql/codeql-config.yml - name: Autobuild - uses: github/codeql-action/autobuild@v4 + uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 with: category: "/language:javascript-typescript" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..aff4a89a5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,157 @@ +name: Verified Release + +on: + push: + tags: + - 'v*' + +permissions: {} + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: Verify, attest and publish release + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: write # Create the GitHub Release and upload its assets. + checks: read # Require every protected-branch check before publishing an RC. + statuses: read # Read legacy commit-status based required checks. + pull-requests: read # Validate prereleases against the exact merge-ready PR head. + id-token: write # Sign the artifact provenance with GitHub OIDC. + attestations: write # Store the artifact provenance attestation. + + steps: + - name: Checkout complete history without persisted credentials + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Verify stable or prerelease source policy + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ github.ref_name }} + run: bash scripts/ci-verify-release-source.sh + + - name: Setup PHP 8.2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: '8.2' + extensions: mysqli, pdo_mysql, mbstring, curl, intl, xml, zip, gd + coverage: none + + - name: Setup Node 22 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: '22' + package-manager-cache: false + + - name: Install locked build inputs + run: | + sudo apt-get update -q + sudo apt-get install -y jq rsync unzip zip + composer validate --strict + composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader + composer audit --no-dev --abandoned=ignore + npm ci --silent + npm audit --audit-level=high + npm --prefix frontend ci --silent + npm --prefix frontend audit --audit-level=high + npm --prefix frontend run lint + npm --prefix frontend run build + npm run test:ci-policy + + - name: Require committed reproducible frontend assets + run: | + git diff --exit-code -- public/assets + untracked_assets="$(git ls-files --others --exclude-standard -- public/assets)" + if [ -n "${untracked_assets}" ]; then + echo "Generated assets are untracked:" + echo "${untracked_assets}" + exit 1 + fi + + - name: Build twice and prove byte-for-byte reproducibility + run: | + bash bin/build-release.sh --skip-build + version=$(jq -r .version version.json) + first_hash=$(sha256sum "releases/pinakes-v${version}.zip" | cut -d' ' -f1) + bash bin/build-release.sh --skip-build + second_hash=$(sha256sum "releases/pinakes-v${version}.zip" | cut -d' ' -f1) + test "$first_hash" = "$second_hash" || { + echo "Release is not reproducible: $first_hash != $second_hash" + exit 1 + } + + - name: Verify exact release archive + run: | + version=$(jq -r .version version.json) + bash scripts/ci-verify-release.sh "releases/pinakes-v${version}.zip" + mkdir release-under-test + unzip -q "releases/pinakes-v${version}.zip" -d release-under-test + + - name: Generate packaged-release SPDX SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + path: release-under-test + format: spdx-json + output-file: releases/pinakes.spdx.json + upload-artifact: false + + - name: Attest release provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: releases/*.zip + + - name: Extract version changelog + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + # The value is supplied by the trusted push tag, not PR-controlled data. + # shellcheck disable=SC2153 + version=${RELEASE_TAG#v} + section=$(awk -v version="$version" \ + '$0 ~ "^## \\[" version "\\]" {flag=1; next} /^## \\[/ {flag=0} flag' \ + CHANGELOG.md) + if [ -z "$(printf '%s' "$section" | tr -d '[:space:]')" ]; then + section="See CHANGELOG.md for details." + fi + { + printf '%s\n\n' "$section" + echo 'The ZIP was built twice with an identical SHA-256, audited as the' + echo 'exact distributable, accompanied by an SPDX SBOM, and signed with' + echo 'a GitHub artifact provenance attestation.' + } > releases/GITHUB_RELEASE_BODY.md + + - name: Publish verified GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ github.ref_name }} + run: | + mapfile -t assets < <(find releases -maxdepth 1 -type f \ + \( -name '*.zip' -o -name '*.zip.sha256' -o -name '*.spdx.json' -o -name 'RELEASE_NOTES-*.md' \) \ + -print | sort) + test "${#assets[@]}" -ge 4 + release_flags=(--verify-tag --title "Pinakes $TAG_NAME" --notes-file releases/GITHUB_RELEASE_BODY.md) + if [[ "$TAG_NAME" == *-* ]]; then + release_flags+=(--prerelease) + else + release_flags+=(--latest) + fi + gh release create "$TAG_NAME" "${assets[@]}" "${release_flags[@]}" + + - name: Retain release evidence in Actions + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: release-evidence-${{ github.ref_name }} + path: | + releases/*.zip + releases/*.sha256 + releases/*.spdx.json + releases/RELEASE_NOTES-*.md + if-no-files-found: error + retention-days: 90 diff --git a/.github/workflows/release.yml.disabled b/.github/workflows/release.yml.disabled deleted file mode 100644 index a99d16e43..000000000 --- a/.github/workflows/release.yml.disabled +++ /dev/null @@ -1,168 +0,0 @@ -name: Create Release Package - -on: - push: - tags: - - 'v*.*.*' - -jobs: - build-release: - name: Build and Package Release - runs-on: ubuntu-latest - permissions: - contents: write # Required to create/update releases - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Get version from tag - id: version - run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - - - name: Setup PHP 8.1 - uses: shivammathur/setup-php@v2 - with: - php-version: '8.1' - extensions: mysqli, pdo, mbstring, json, openssl, curl - tools: composer - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' - cache: 'npm' - cache-dependency-path: frontend/package-lock.json - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y jq rsync zip - - - name: Install Composer dependencies - run: composer install --no-dev --optimize-autoloader --no-interaction - - - name: Install NPM dependencies - working-directory: frontend - run: npm ci - - - name: Build frontend assets - working-directory: frontend - run: npm run build - - - name: Verify version.json matches tag - run: | - VERSION_JSON=$(jq -r '.version' version.json) - if [ "$VERSION_JSON" != "${{ steps.version.outputs.VERSION }}" ]; then - echo "❌ Version mismatch: version.json ($VERSION_JSON) != tag (${{ steps.version.outputs.VERSION }})" - exit 1 - fi - echo "✅ Version verified: $VERSION_JSON" - - - name: Make build script executable - run: chmod +x bin/build-release.sh - - - name: Create release package - run: ./bin/build-release.sh - - - name: Verify package files exist - run: | - if [ ! -f "releases/pinakes-v${{ steps.version.outputs.VERSION }}.zip" ]; then - echo "❌ Release ZIP not found" - exit 1 - fi - if [ ! -f "releases/pinakes-v${{ steps.version.outputs.VERSION }}.zip.sha256" ]; then - echo "❌ Checksum file not found" - exit 1 - fi - if [ ! -f "releases/RELEASE_NOTES-v${{ steps.version.outputs.VERSION }}.md" ]; then - echo "❌ Release notes not found" - exit 1 - fi - echo "✅ All package files verified" - - - name: Extract changelog for this version - id: changelog - run: | - if [ -f "CHANGELOG.md" ]; then - # Extract changelog section for this version - VERSION_SECTION=$(awk "/## \[${VERSION}\]/{flag=1;next}/## \[/{flag=0}flag" CHANGELOG.md || echo "See CHANGELOG.md for details") - echo "CHANGELOG<> $GITHUB_OUTPUT - echo "$VERSION_SECTION" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - else - echo "CHANGELOG=See release notes for details" >> $GITHUB_OUTPUT - fi - env: - VERSION: ${{ steps.version.outputs.VERSION }} - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - name: Pinakes v${{ steps.version.outputs.VERSION }} - body: | - # Pinakes v${{ steps.version.outputs.VERSION }} - - > ⚠️ **UPGRADING FROM v0.4.1 - v0.4.3?** - > - > The built-in updater has a bug that prevents automatic updates on shared hosting. - > **You must manually patch the updater first:** - > - > 1. Download `test-updater/` folder from this release - > 2. Upload the `app/` folder via FTP, overwriting existing files - > 3. Then retry the update from Admin → Updates - > - > Or upload `test-updater/manual-update.php` to your site root and access it via browser. - > See `test-updater/README.md` for detailed instructions. - - --- - - ${{ steps.changelog.outputs.CHANGELOG }} - - --- - - ## 📦 Download - - Download `pinakes-v${{ steps.version.outputs.VERSION }}.zip` and verify checksum: - - ```bash - shasum -a 256 -c pinakes-v${{ steps.version.outputs.VERSION }}.zip.sha256 - ``` - - ## 📋 Installation (New Installations) - - 1. Extract archive: - ```bash - unzip pinakes-v${{ steps.version.outputs.VERSION }}.zip - cd pinakes-v${{ steps.version.outputs.VERSION }} - ``` - - 2. Configure environment: - ```bash - cp .env.example .env - # Edit .env with your settings - ``` - - 3. Run web installer at: http://yourdomain.com - - See README.md in the package for complete documentation. - files: | - releases/pinakes-v${{ steps.version.outputs.VERSION }}.zip - releases/pinakes-v${{ steps.version.outputs.VERSION }}.zip.sha256 - releases/RELEASE_NOTES-v${{ steps.version.outputs.VERSION }}.md - draft: false - prerelease: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Upload release artifacts - uses: actions/upload-artifact@v4 - with: - name: release-package-v${{ steps.version.outputs.VERSION }} - path: | - releases/pinakes-v${{ steps.version.outputs.VERSION }}.zip - releases/pinakes-v${{ steps.version.outputs.VERSION }}.zip.sha256 - releases/RELEASE_NOTES-v${{ steps.version.outputs.VERSION }}.md - retention-days: 90 diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml index b4845698c..82fda11bc 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -8,11 +8,7 @@ on: - 'version.json' - '.github/workflows/test-migrations.yml' pull_request: - paths: - - 'installer/database/migrations/**' - - 'installer/database/schema.sql' - - 'version.json' - - '.github/workflows/test-migrations.yml' + branches: [main] workflow_dispatch: permissions: @@ -30,14 +26,15 @@ jobs: name: Verify migration versions ≤ release runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: '8.2' coverage: none - github-token: '' - name: Check all migrate_*.sql ≤ version.json run: | @@ -46,14 +43,15 @@ jobs: FAILED=0 for f in installer/database/migrations/migrate_*.sql; do V=$(basename "$f" | sed 's/migrate_//;s/\.sql//') - if php -r "exit(version_compare('$V', '$TARGET', '<=') ? 0 : 1);"; then - echo " ✓ $(basename $f) ($V)" + if MIGRATION_VERSION="$V" RELEASE_VERSION="$TARGET" php -r \ + 'exit(version_compare(getenv("MIGRATION_VERSION"), getenv("RELEASE_VERSION"), "<=") ? 0 : 1);'; then + echo " ✓ $(basename "$f") ($V)" else - echo " ✗ $(basename $f) ($V > $TARGET) — would be silently skipped by updater!" + echo " ✗ $(basename "$f") ($V > $TARGET) — would be silently skipped by updater!" FAILED=1 fi done - exit $FAILED + exit "$FAILED" # ─── Job 2: full migration chain — schema.sql + every migration in order ─── # Tests idempotency: migrations use CREATE TABLE IF NOT EXISTS / SET @s := IF(@c=0,...) @@ -67,7 +65,7 @@ jobs: services: mysql: - image: mysql:8.0 + image: mysql:8.0@sha256:7dcddc01f13bab2f15cde676d44d01f61fc9f99fe7785e86196dfc07d358ae2b env: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: pinakes_test @@ -80,15 +78,16 @@ jobs: --health-retries=5 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: '8.2' extensions: mysqli coverage: none - github-token: '' - name: Wait for MySQL run: until mysqladmin ping -h"127.0.0.1" --silent; do sleep 1; done @@ -99,8 +98,9 @@ jobs: - name: Apply all migrations in version order run: | FAILED=0 - for sql in $(ls installer/database/migrations/migrate_*.sql | sort -V); do - echo "→ $(basename $sql)" + mapfile -t MIGRATIONS < <(find installer/database/migrations -maxdepth 1 -type f -name 'migrate_*.sql' | sort -V) + for sql in "${MIGRATIONS[@]}"; do + echo "→ $(basename "$sql")" OUTPUT=$(mysql --force -h 127.0.0.1 -u root -proot pinakes_test < "$sql" 2>&1) || true # Check each ERROR line individually — skip only known-idempotent ones while IFS= read -r line; do @@ -124,7 +124,12 @@ jobs: AND table_name IN ('archival_units','authority_records','collane','volumi','libri_collane'); ") echo "Tables found: $COUNT / 5" - [ "$COUNT" -eq 5 ] && echo "✓ All expected tables present" || { echo "✗ Missing tables"; exit 1; } + if [ "$COUNT" -eq 5 ]; then + echo "✓ All expected tables present" + else + echo "✗ Missing tables" + exit 1 + fi - name: Verify columns from 0.5.9.x migrations run: | @@ -138,7 +143,12 @@ jobs: ); ") echo "Columns found: $COUNT / 7" - [ "$COUNT" -eq 7 ] && echo "✓ All expected columns present" || { echo "✗ Missing columns"; exit 1; } + if [ "$COUNT" -eq 7 ]; then + echo "✓ All expected columns present" + else + echo "✗ Missing columns" + exit 1 + fi - name: Verify ENUM values after full migration chain run: | @@ -184,11 +194,12 @@ jobs: # ─── Job 3: existing single-migration test (upgrade 0.3.x → 0.4.0) ───────── test-migrations: + name: Legacy migration regression (0.3.x to 0.4.0) runs-on: ubuntu-latest services: mysql: - image: mysql:8.0 + image: mysql:8.0@sha256:7dcddc01f13bab2f15cde676d44d01f61fc9f99fe7785e86196dfc07d358ae2b env: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: pinakes_test @@ -201,14 +212,15 @@ jobs: --health-retries=5 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: '8.2' extensions: mysqli, pdo_mysql - github-token: '' - name: Wait for MySQL run: | diff --git a/.gitignore b/.gitignore index 886a71895..c90055a0c 100644 --- a/.gitignore +++ b/.gitignore @@ -421,6 +421,7 @@ tests/* !tests/*.config.js !tests/*.unit.php !tests/*.test.sh +!tests/ci-playwright-policy.json !tests/seeds/ tests/seeds/* !tests/seeds/*.json diff --git a/.nginx.conf.example b/.nginx.conf.example index 4ac3ae479..ddd7aa7b5 100644 --- a/.nginx.conf.example +++ b/.nginx.conf.example @@ -72,7 +72,7 @@ server { add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; # Uncomment and adjust CSP for production: - # add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: https: http: blob:; connect-src 'self'; frame-src 'self' https://www.openstreetmap.org; frame-ancestors 'self';" always; + # Content-Security-Policy is emitted by the application with a unique nonce. # ======================================== # Block access to sensitive/hidden files diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/.rsync-filter b/.rsync-filter index 3c8f52d11..eb5de57f8 100644 --- a/.rsync-filter +++ b/.rsync-filter @@ -122,6 +122,17 @@ - scripts/clean-git-history.sh - scripts/copy-vendor-assets.js - scripts/export-email-templates-to-installer.php +- /scripts/ci-* +- /scripts/create-release*.sh +- /scripts/list-source-expectations.php +- /scripts/reinstall-test.sh +- /scripts/remote-pr-verify.* +- /scripts/verify-schema.sh + +# Root Node tooling exists only for Playwright/CI. The independently usable +# frontend/ package remains available to installations that customize assets. +- /package.json +- /package-lock.json # Development dependencies # node_modules/ without leading / catches any level (intentional: vendor packages too) diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 000000000..94c051d61 --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,10 @@ +extends: default + +rules: + document-start: disable + line-length: disable + truthy: + allowed-values: ['true', 'false', 'on'] + comments: + min-spaces-from-content: 1 + colons: disable diff --git a/CHANGELOG.md b/CHANGELOG.md index 3560127b0..6d25b4449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,38 @@ Full version-by-version history for Pinakes. The README shows only the latest release; everything older lives here. -## What's New in v0.7.53 +## [0.7.59] + +Consolidation release: the complete integration of PRs #335, #337, #339 and +#340, hardened together with the fixes found during their combined review and a +security pass. Validated through three release candidates. + +### Fixes + +- Loan and reservation state, availability and date handling now use the same + guarded production paths across the web UI, mobile API and background jobs. +- Orphan plugin hooks are disabled reversibly and the regression test creates + its own foreign-key-safe fixture, including on a completely fresh database. +- Framework-generated error responses receive the same nonce-based Content + Security Policy as normal pages. +- Review findings covering migration selection, fixture cleanup, SweetAlert + confirmation, localization parity, generated assets and accessibility have + regression coverage. + +### Release verification + +- The exact distributable is installed and exercised through the full E2E and + four-shard browser regression suites. +- Chromium, Firefox and WebKit run accessibility/runtime checks; OWASP ZAP + blocks medium/high passive-scan findings. +- PHP 8.2–8.5, MySQL 8.0/8.4 and MariaDB 10.11/11.4 are verified, together with + fresh installs in all five bundled locales and the complete upgrade chain. +- Static analysis, dependency/secret/vulnerability scans, reproducible archive + checks, SPDX SBOM and provenance are mandatory release gates. + +--- + +## [0.7.53] A performance release: the whole application answers faster, with a dramatically lighter database footprint per request. @@ -1263,4 +1294,3 @@ third-party overwrite can slip through unnoticed. Full post-mortem in --- - diff --git a/README.md b/README.md index 459aa87f1..928416c29 100644 --- a/README.md +++ b/README.md @@ -39,17 +39,28 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and pri ## What's New -Highlights of the latest release are below. The full version-by-version history (v0.7.52 → v0.6.x) lives in **[CHANGELOG.md](CHANGELOG.md)**. +Highlights of the latest release are below. The full version-by-version history (v0.7.58 → v0.6.x) lives in **[CHANGELOG.md](CHANGELOG.md)**. -### v0.7.52 — latest +### v0.7.59 — latest -A mobile alignment fix for the book page. +A consolidation release: four feature/fix branches integrated and hardened together, with a security pass on top. + +### New +- **"Complete series" indicator** — admins can mark a series as complete; the flag shows on the series list and detail pages (#338). ### Fixes -- **The book info and share cards line up with the rest of the sheet on mobile** — their contents were inset by the cards' own horizontal padding, sitting further right than the title, the description and the section headings. On phones that padding is dropped so the metadata rows and the social buttons sit flush at the same edge as everything else. +- **Loan editing from the admin page works again** — the availability re-check no longer bounces every edit, and dates are validated strictly (#336). +- **Loan status no longer shows "Unknown"** — cancelled and expired loans render their real state through canonical status/label helpers (#333). +- **The notifications panel is no longer overlapped** in the admin during scroll (#334). +- **Loan & reservation coherence** — clocks, availability and date handling share the same guarded paths across the web UI, mobile API and background jobs; auto-approval now honours the setting on the book-detail request path too (#301). +- **The barcode scanner keeps focus** on the loan form and copies sort in natural order (#238). +- **Related books are reachable on narrow and tablet screens** — the strip now shows a scroll affordance instead of silently clipping cards. + +### Security +- Framework-generated error responses now receive the same nonce-based Content Security Policy as normal pages. ### Database Changes -- None — a view and the compiled layout CSS only. +- Adds a `collane.is_completa` flag; the in-app updater applies the migration automatically on upgrade. ### Upgrade Notes - Back up your database before updating (the in-app updater does this automatically). diff --git a/app/Controllers/CollaneController.php b/app/Controllers/CollaneController.php index 47457d380..e098168b3 100644 --- a/app/Controllers/CollaneController.php +++ b/app/Controllers/CollaneController.php @@ -50,6 +50,7 @@ public function index(Request $request, Response $response, mysqli $db): Respons { $seriesRepo = new SeriesRepository($db); $supportsHierarchy = $seriesRepo->supportsHierarchy(); + $supportsCompleteFlag = $seriesRepo->supportsCompleteFlag(); $collane = $seriesRepo->listSeries(); ob_start(); @@ -74,6 +75,7 @@ public function show(Request $request, Response $response, mysqli $db): Response $seriesRepo = new SeriesRepository($db); $supportsHierarchy = $seriesRepo->supportsHierarchy(); + $supportsCompleteFlag = $seriesRepo->supportsCompleteFlag(); // Get collana metadata from collane table $collanaDesc = ''; @@ -82,6 +84,7 @@ public function show(Request $request, Response $response, mysqli $db): Response $cycleOrder = null; $seriesParent = ''; $seriesType = 'serie'; + $seriesComplete = false; $seriesRepo->ensureCollana($collana, [], false); $metaRow = $seriesRepo->getSeriesByName($collana); if ($metaRow) { @@ -91,6 +94,7 @@ public function show(Request $request, Response $response, mysqli $db): Response $cycleOrder = $metaRow['ordine_ciclo'] ?? null; $seriesParent = $metaRow['parent_nome'] ?? ''; $seriesType = $metaRow['tipo'] ?? 'serie'; + $seriesComplete = (int) ($metaRow['is_completa'] ?? 0) === 1; } $relatedCollane = $seriesRepo->getRelatedSeries($collana); @@ -224,6 +228,8 @@ public function saveDescription(Request $request, Response $response, mysqli $db $seriesCycle = $this->nullableString($data['ciclo'] ?? null); $cycleOrder = $this->nullableCycleOrder($data['ordine_ciclo'] ?? null); $seriesParent = $this->nullableString($data['serie_padre'] ?? null); + $rawSeriesComplete = $data['is_completa'] ?? null; + $seriesComplete = is_scalar($rawSeriesComplete) && (string) $rawSeriesComplete === '1'; $seriesRepo = new SeriesRepository($db); $seriesType = $seriesRepo->normalizeType((string) ($data['tipo_collana'] ?? 'serie')); @@ -243,9 +249,10 @@ public function saveDescription(Request $request, Response $response, mysqli $db 'ordine_ciclo' => $cycleOrder, 'parent_nome' => $seriesParent, 'tipo' => $seriesType, + 'is_completa' => $seriesComplete, ]); - $_SESSION['success_message'] = __('Descrizione salvata'); + $_SESSION['success_message'] = __('Metadati serie salvati'); return $response->withHeader('Location', url('/admin/series/detail?nome=' . urlencode($nome)))->withStatus(302); } diff --git a/app/Controllers/FrontendController.php b/app/Controllers/FrontendController.php index 8fc76988c..f1c74b39f 100644 --- a/app/Controllers/FrontendController.php +++ b/app/Controllers/FrontendController.php @@ -645,6 +645,14 @@ public function bookDetail(Request $request, Response $response, mysqli $db): Re $shareUrl = absoluteUrl($canonicalPath); $shareTitle = $book['titolo'] ?? ''; + // Keep the public request calendar aligned with the server-side default + // used when end_date is omitted. The reservation endpoint caps that + // default to max_loan_duration_days, so expose the same effective value + // to the view instead of hardcoding one calendar month in JavaScript. + $loanSettings = new \App\Models\SettingsRepository($db); + $maxRequestDays = max(1, (int) ($loanSettings->get('loans', 'max_loan_duration_days', '90') ?? 90)); + $defaultRequestLoanDays = min($loanSettings->loanDurationDays(), $maxRequestDays); + // Check whether the BIBFRAME Linked Data plugin is active. // Done before template include so the view can use $bibframePluginActive. // Uses PluginManager::isActive() which caches per-process — the raw diff --git a/app/Controllers/LibraryThingImportController.php b/app/Controllers/LibraryThingImportController.php index 5d1048d26..206920b3d 100644 --- a/app/Controllers/LibraryThingImportController.php +++ b/app/Controllers/LibraryThingImportController.php @@ -2153,7 +2153,7 @@ public function exportToLibraryThing(Request $request, Response $response, \mysq $bindValues[] = $autoreId; } - // Build query + // Build query. CI-SOFT-DELETE-EXEMPT: l.deleted_at IS NULL is appended to this exact query before execution below. $query = " SELECT l.*, diff --git a/app/Controllers/LibriApiController.php b/app/Controllers/LibriApiController.php index a8104e70b..025b90b46 100644 --- a/app/Controllers/LibriApiController.php +++ b/app/Controllers/LibriApiController.php @@ -190,6 +190,7 @@ public function list(Request $request, Response $response, mysqli $db): Response $total = (int) ($total_res->fetch_assoc()['c'] ?? 0); // Use prepared statement for filtered count + // CI-SOFT-DELETE-EXEMPT: $where is initialized above with WHERE l.deleted_at IS NULL and cannot be replaced. $count_sql = 'SELECT COUNT(*) AS c FROM libri l ' . $where; $count_stmt = $db->prepare($count_sql); if (!$count_stmt) { @@ -210,6 +211,7 @@ public function list(Request $request, Response $response, mysqli $db): Response $authorLabelExpr = $this->hasTableColumn($db, 'autori', 'cognome') ? "CONCAT(a.nome, ' ', a.cognome)" : \App\Support\AuthorName::displaySql('a'); + // CI-SOFT-DELETE-EXEMPT: the immutable $where prefix injects l.deleted_at IS NULL into this exact list query. $sql = "SELECT l.*, e.nome AS editore_nome, g.nome AS genere_nome, COALESCE(s.nome, 'N/D') AS collocazione_nome, diff --git a/app/Controllers/LibriController.php b/app/Controllers/LibriController.php index bda9664d3..9494cde94 100644 --- a/app/Controllers/LibriController.php +++ b/app/Controllers/LibriController.php @@ -2252,7 +2252,7 @@ private function handleCoverUpload(mysqli $db, int $bookId, array $file): void */ private function currentCoverUrl(mysqli $db, int $bookId): string { - $stmt = $db->prepare('SELECT copertina_url FROM libri WHERE id=?'); + $stmt = $db->prepare('SELECT copertina_url FROM libri WHERE id=? AND deleted_at IS NULL'); if (!$stmt) { return ''; } @@ -2858,6 +2858,7 @@ public function generateCopyLabelsPDF(Request $request, Response $response, mysq $copie[] = $copyRow; } $copiesStmt->close(); + $copie = \App\Models\CopyRepository::sortByInventoryNumber($copie); if (count($copie) === 0) { $_SESSION['error_message'] = __('Nessuna copia disponibile per la stampa delle etichette.'); @@ -3329,7 +3330,7 @@ public function exportCsv(Request $request, Response $response, mysqli $db): Res $bindValues[] = $autoreId; } - // Build the query + // Build the query. CI-SOFT-DELETE-EXEMPT: l.deleted_at IS NULL is appended to this exact export before prepare/query. $query = " SELECT l.*, diff --git a/app/Controllers/LoanApprovalController.php b/app/Controllers/LoanApprovalController.php index ffd7f8e72..186ca6bb6 100644 --- a/app/Controllers/LoanApprovalController.php +++ b/app/Controllers/LoanApprovalController.php @@ -548,7 +548,13 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R 'success' => true, 'message' => $isFutureLoan ? __('Prestito prenotato con successo') - : __('Prestito approvato - in attesa di ritiro') + : __('Prestito approvato - in attesa di ritiro'), + // Expose the state actually persisted so programmatic callers + // (autoApproveLoanRequest) can branch on 'prenotato' vs + // 'da_ritirare' instead of re-deriving it from the date and + // drifting from the authoritative computation above. + 'loan_state' => $newState, + 'is_future_loan' => $isFutureLoan, ])); return $response->withHeader('Content-Type', 'application/json'); @@ -577,14 +583,19 @@ public function rejectLoan(Request $request, Response $response, mysqli $db): Re } } $loanId = (int) ($data['loan_id'] ?? 0); - $reason = $data['reason'] ?? ''; + // Used both in the audit note and the rejection email: normalize scalar + // input once and apply the admin workflow's explicit 500-character bound. + // `prestiti.note` is TEXT: this is an input-policy limit, not a claim + // about the persistence column's capacity. + $rawReason = $data['reason'] ?? ''; + $reason = is_scalar($rawReason) ? mb_substr(trim((string) $rawReason), 0, 500) : ''; if ($loanId <= 0) { $response->getBody()->write(json_encode(['success' => false, 'message' => __('ID prestito non valido')])); return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } - // Start transaction for atomic delete + availability update + // Start transaction for the atomic terminal transition + availability update. $db->begin_transaction(); try { @@ -611,6 +622,7 @@ public function rejectLoan(Request $request, Response $response, mysqli $db): Re // deliberata al soft-delete invariant): rifiutare una richiesta pendente // deve funzionare ANCHE se il libro è stato soft-eliminato nel frattempo — // filtrare renderebbe la query vuota e lascerebbe la richiesta orfana. + // CI-SOFT-DELETE-EXEMPT: rejection must release pending circulation state for a deleted book. $lockBook = $db->prepare('SELECT id FROM libri WHERE id = ? FOR UPDATE'); $lockBook->bind_param('i', $bookId); $lockBook->execute(); @@ -625,7 +637,8 @@ public function rejectLoan(Request $request, Response $response, mysqli $db): Re return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } - // Fetch FULL loan data under lock before deletion (needed for email). + // Fetch full loan data under lock before changing state (needed for email). + // CI-SOFT-DELETE-EXEMPT: rejection needs the deleted book title while releasing its pending loan. $stmt = $db->prepare(" SELECT p.libro_id, p.utente_id, l.titolo as libro_titolo, CONCAT(u.nome, ' ', u.cognome) as utente_nome, u.email as utente_email @@ -653,14 +666,29 @@ public function rejectLoan(Request $request, Response $response, mysqli $db): Re if ((int) $loan['libro_id'] !== $bookId) { throw new \RuntimeException('libro_id del prestito cambiato durante il lock (TOCTOU).'); } - // Store data needed for rejection email BEFORE deletion + // Store data needed for the rejection email before changing state. $userEmail = $loan['utente_email']; $userName = $loan['utente_nome']; $bookTitle = $loan['libro_titolo']; - // Delete the loan - $stmt = $db->prepare("DELETE FROM prestiti WHERE id = ? AND stato = 'pendente'"); - $stmt->bind_param('i', $loanId); + // Mark as annullato instead of deleting: the rejection was the only + // terminal transition that destroyed its row, leaving no audit of + // who rejected what and blinding the statistics. Same shape as the + // user cancel path (stato='annullato', attivo=0, processed_by, note); + // the duplicate-request checks ignore 'annullato', so the user can + // request the same book again. + $rejectedBy = isset($_SESSION['user']['id']) ? (int) $_SESSION['user']['id'] : null; + $rejectNote = "\n[Admin] " . __('Richiesta rifiutata'); + if ($reason !== '') { + $rejectNote .= ': ' . $reason; + } + $stmt = $db->prepare(" + UPDATE prestiti + SET stato = 'annullato', attivo = 0, processed_by = ?, + note = CONCAT(COALESCE(note, ''), ?), updated_at = NOW() + WHERE id = ? AND stato = 'pendente' + "); + $stmt->bind_param('isi', $rejectedBy, $rejectNote, $loanId); $stmt->execute(); if ($db->affected_rows === 0) { @@ -679,10 +707,30 @@ public function rejectLoan(Request $request, Response $response, mysqli $db): Re throw new \RuntimeException('Failed to recalculate book availability'); } + // Promote the waitlist: a rejected reservation-conversion 'pendente' + // held a copy, and every other release path (return, cancel, expiry) + // immediately converts the next queued reservation — rejectLoan was + // the only one that left the freed capacity idle until the next + // maintenance run. processBookAvailability() is a no-op for bare + // pendings (nothing was occupied) and for soft-deleted books. + $reservationManager = new \App\Controllers\ReservationManager($db); + $reservationManager->setExternalTransaction(true); + for ($promoGuard = 0; $promoGuard < 1000 && $reservationManager->processBookAvailability($bookId); $promoGuard++) { + // keep promoting while freed capacity converts the next queued reservation + } + $db->commit(); + // Notifiche accodate durante la transazione esterna (P2): inviale ora + // che il commit è avvenuto, come fa MaintenanceService. + try { + $reservationManager->flushDeferredNotifications(); + } catch (\Throwable $flushError) { + \App\Support\SecureLogger::warning("[rejectLoan] Deferred notification flush failed: " . $flushError->getMessage()); + } + // Send notification AFTER successful commit (outside transaction) - // Use pre-fetched data since loan is deleted + // Use the pre-fetched data because the row is now in a terminal state. try { $notificationService = new \App\Support\NotificationService($db); $notificationService->sendLoanRejectedNotificationDirect( @@ -765,6 +813,7 @@ public function confirmPickup(Request $request, Response $response, mysqli $db): // Lock della riga `libri` SENZA filtro deleted_at: come per le restituzioni // (vedi LoanRepository::close), l'evasione di un prestito già approvato deve // poter procedere anche se il libro è stato soft-deleted nel frattempo. + // CI-SOFT-DELETE-EXEMPT: pickup must finish an already-approved loan for a deleted book. $lockBookStmt = $db->prepare("SELECT id FROM libri WHERE id = ? FOR UPDATE"); $lockBookStmt->bind_param('i', $libroId); $lockBookStmt->execute(); @@ -954,6 +1003,7 @@ public function cancelPickup(Request $request, Response $response, mysqli $db): // ritiro deve sempre poter procedere anche su libro soft-deleted (vedi // LoanRepository::close), altrimenti prestito e copia resterebbero // impegnati per sempre. + // CI-SOFT-DELETE-EXEMPT: cancellation must free circulation state for a deleted book. $lockBookStmt = $db->prepare("SELECT id FROM libri WHERE id = ? FOR UPDATE"); $lockBookStmt->bind_param('i', $libroId); $lockBookStmt->execute(); @@ -1126,6 +1176,7 @@ public function returnLoan(Request $request, Response $response, mysqli $db): Re // sempre poter procedere anche su libro soft-deleted (vedi il commento in // LoanRepository::close), altrimenti prestito e copia resterebbero // occupati per sempre. + // CI-SOFT-DELETE-EXEMPT: return must release an active loan and copy for a deleted book. $lockBookStmt = $db->prepare("SELECT id FROM libri WHERE id = ? FOR UPDATE"); $lockBookStmt->bind_param('i', $libroId); $lockBookStmt->execute(); @@ -1326,6 +1377,7 @@ public function cancelReservation(Request $request, Response $response, mysqli $ // Lock della riga `libri` SENZA filtro deleted_at: l'annullamento di una // prenotazione deve sempre poter procedere anche su libro soft-deleted // (vedi LoanRepository::close), per non lasciare la coda bloccata. + // CI-SOFT-DELETE-EXEMPT: reservation cancellation must unblock the queue for a deleted book. $lockBookStmt = $db->prepare("SELECT id FROM libri WHERE id = ? FOR UPDATE"); $lockBookStmt->bind_param('i', $libroId); $lockBookStmt->execute(); @@ -1334,6 +1386,7 @@ public function cancelReservation(Request $request, Response $response, mysqli $ // Poi lock + ri-verifica della prenotazione. Il JOIN recupera anche // destinatario e titolo per la notifica post-commit (M11), come fa // rejectLoan; niente filtro deleted_at sul libro (vedi sopra). + // CI-SOFT-DELETE-EXEMPT: cancellation notification needs the deleted book title for the affected user. $stmt = $db->prepare(" SELECT r.libro_id, l.titolo as libro_titolo, CONCAT(u.nome, ' ', u.cognome) as utente_nome, u.email as utente_email diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php index 087b8d14f..a0a9408ff 100644 --- a/app/Controllers/PrestitiController.php +++ b/app/Controllers/PrestitiController.php @@ -168,6 +168,13 @@ public function createForm(Request $request, Response $response, mysqli $db): Re } } + // Prefill delle date nel timezone APPLICATIVO e con la durata configurata: + // il vecchio date('Y-m-d') (TZ processo, spesso UTC) mostrava "ieri" dopo + // mezzanotte, e il '+1 month' della view divergeva dal default server (30gg). + $defaultDataPrestito = \App\Support\DateHelper::today(); + $defaultLoanDays = (new \App\Models\SettingsRepository($db))->loanDurationDays(); + $defaultDataScadenza = date('Y-m-d', strtotime($defaultDataPrestito . " +{$defaultLoanDays} days")); + ob_start(); require __DIR__ . '/../Views/prestiti/crea_prestito.php'; $content = ob_get_clean(); @@ -224,21 +231,44 @@ public function store(Request $request, Response $response, mysqli $db): Respons if (empty($data_prestito)) { $data_prestito = \App\Support\DateHelper::today(); } + + // Validate the start date before deriving the default deadline. Feeding + // malformed user input to strtotime() used to normalize ambiguous dates + // (and could throw on NUL bytes) before the validation below ran. + if (!\App\Support\DateHelper::isISODateFormat($data_prestito)) { + return $response->withHeader('Location', url('/admin/loans/create') . '?error=invalid_date_format')->withStatus(302); + } if (empty($data_scadenza)) { // Default loan duration read from admin settings (fallback: 30 days) - $loanDays = (int) ((new \App\Models\SettingsRepository($db))->get('loans', 'loan_duration_days', '30') ?? 30); - if ($loanDays < 1) { - $loanDays = 30; + $loanDays = (new \App\Models\SettingsRepository($db))->loanDurationDays(); + $startDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $data_prestito); + if ($startDate === false) { + return $response->withHeader('Location', url('/admin/loans/create') . '?error=invalid_date_format')->withStatus(302); } - $data_scadenza = date('Y-m-d', strtotime($data_prestito . " +{$loanDays} days")); + $data_scadenza = $startDate->modify("+{$loanDays} days")->format('Y-m-d'); } if ($utente_id <= 0 || $libro_id <= 0) { return $response->withHeader('Location', url('/admin/loans/create') . '?error=missing_fields')->withStatus(302); } + // Validazione ISO stretta di ENTRAMBE le date (stessa regola di update()): + // il vecchio guard `strtotime($a) <= strtotime($b)` con una data non + // parsabile confrontava int con false in modo booleano e PASSAVA, e + // l'ambiguità '12/03/2026' veniva letta all'americana (3 dicembre). + // L'input arriva libero (il campo è data-no-flatpickr), quindi qui è + // l'unico punto di difesa prima dell'INSERT. + // F028: un formato non-ISO e un range invertito sono due errori diversi. + // Separo i codici così l'admin non legge "la scadenza deve essere + // successiva" quando il vero problema è il formato — con l'indicazione + // esplicita del formato atteso YYYY-MM-DD. + if (!\App\Support\DateHelper::isISODateFormat($data_scadenza)) { + return $response->withHeader('Location', url('/admin/loans/create') . '?error=invalid_date_format')->withStatus(302); + } + // Verifica che la data di scadenza sia successiva alla data di prestito - if (strtotime($data_scadenza) <= strtotime($data_prestito)) { + // (confronto lessicografico sicuro: entrambe validate Y-m-d qui sopra) + if ($data_scadenza <= $data_prestito) { return $response->withHeader('Location', url('/admin/loans/create') . '?error=invalid_dates')->withStatus(302); } @@ -712,6 +742,7 @@ public function update(Request $request, Response $response, mysqli $db, int $id // data_restituzione/attivo/stato sono gestiti dal form "Registra Restituzione". $allowedFields = ['utente_id', 'data_prestito', 'data_scadenza']; $updateData = []; + $invalidDateInput = false; foreach ($allowedFields as $field) { if (isset($data[$field])) { switch ($field) { @@ -720,11 +751,18 @@ public function update(Request $request, Response $response, mysqli $db, int $id break; case 'data_prestito': case 'data_scadenza': + if (!is_string($data[$field])) { + $invalidDateInput = true; + break; + } $updateData[$field] = $data[$field]; break; } } } + if ($invalidDateInput) { + return $response->withHeader('Location', url('/admin/loans') . '?error=invalid_date_format')->withStatus(302); + } $updateData['processed_by'] = $processedBy; // Lettura NON bloccante della riga corrente: serve libro_id per il lock @@ -755,8 +793,15 @@ public function update(Request $request, Response $response, mysqli $db, int $id // giornata singola) è lecita: createReservation accetta end == start, // quindi un rifiuto strettamente esclusivo renderebbe immodificabili // i prestiti a giornata nati dal calendario utente. - if (strtotime($newScadenza) === false || strtotime($newPrestito) === false - || strtotime($newScadenza) < strtotime($newPrestito)) { + // F028 (#335) + strict-ISO (#337): valida entrambe le date in Y-m-d + // STRETTO senza strtotime, con CODICI D'ERRORE SEPARATI — formato non + // valido vs range invertito — così l'admin riceve un messaggio azionabile. + // DateHelper rifiuta date ambigue o inesistenti (2026-02-30) e byte NUL; + // la ri-validazione sotto lock più in basso applica lo stesso criterio. + if (!\App\Support\DateHelper::isISODateFormat($newPrestito) || !\App\Support\DateHelper::isISODateFormat($newScadenza)) { + return $response->withHeader('Location', url('/admin/loans') . '?error=invalid_date_format')->withStatus(302); + } + if ($newScadenza < $newPrestito) { return $response->withHeader('Location', url('/admin/loans') . '?error=invalid_dates')->withStatus(302); } @@ -766,6 +811,7 @@ public function update(Request $request, Response $response, mysqli $db, int $id // come store/renew/close. Niente filtro deleted_at: la modifica di un // prestito esistente deve poter procedere anche su libro soft-deleted // (stessa regola dei rientri in LoanRepository::close()). + // CI-SOFT-DELETE-EXEMPT: editing an existing loan must remain possible after its book is deleted. $lockBook = $db->prepare('SELECT id FROM libri WHERE id = ? FOR UPDATE'); $lockBook->bind_param('i', $libroId); $lockBook->execute(); @@ -774,7 +820,11 @@ public function update(Request $request, Response $response, mysqli $db, int $id // Lock del prestito e ri-verifica sotto lock: stato aperto invariato e // libro_id non cambiato (TOCTOU sulla lettura non bloccante iniziale). - $lockLoan = $db->prepare('SELECT attivo, data_restituzione, libro_id, utente_id FROM prestiti WHERE id=? FOR UPDATE'); + // Rilegge anche le DATE correnti: un update concorrente tra la lettura + // iniziale e questo lock le può aver cambiate, e la finestra "vecchia" + // del check di capacità qui sotto deve basarsi sui valori realmente + // salvati, non su quelli pre-transazione (CodeRabbit, PR #337). + $lockLoan = $db->prepare('SELECT attivo, data_restituzione, libro_id, copia_id, utente_id, data_prestito, data_scadenza FROM prestiti WHERE id=? FOR UPDATE'); $lockLoan->bind_param('i', $id); $lockLoan->execute(); $locked = $lockLoan->get_result()->fetch_assoc(); @@ -788,6 +838,22 @@ public function update(Request $request, Response $response, mysqli $db, int $id return $response->withHeader('Location', url('/admin/loans') . '?error=loan_update_failed')->withStatus(302); } + // Ricostruisci i valori effettivi dai dati LOCKATI: i campi non inviati + // dal form devono completarsi con lo stato corrente reale della riga. + // Ri-valida il range con gli stessi criteri del pre-check (che resta + // come fast-fail senza aprire la transazione). + $newUserId = isset($updateData['utente_id']) ? (int) $updateData['utente_id'] : (int) $locked['utente_id']; + $newPrestito = (string) ($updateData['data_prestito'] ?? $locked['data_prestito']); + $newScadenza = (string) ($updateData['data_scadenza'] ?? $locked['data_scadenza']); + if (!\App\Support\DateHelper::isISODateFormat($newPrestito) || !\App\Support\DateHelper::isISODateFormat($newScadenza)) { + $db->rollback(); + return $response->withHeader('Location', url('/admin/loans') . '?error=invalid_date_format')->withStatus(302); + } + if ($newScadenza < $newPrestito) { + $db->rollback(); + return $response->withHeader('Location', url('/admin/loans') . '?error=invalid_dates')->withStatus(302); + } + // Se l'utente cambia, ri-esegui i controlli di store() (M6b): il campo // arriva da hidden field e senza ricontrolli permetterebbe di aggirare // idoneità e dup-check assegnando il prestito a un altro utente. @@ -858,15 +924,68 @@ public function update(Request $request, Response $response, mysqli $db, int $id } } - // #11: if the loan is being RESCHEDULED, re-check the new window against + // #11: if the loan is being RESCHEDULED, re-check the new dates against // overlapping loans + queue reservations vs capacity (renew() does this — update() // used to accept any new dates and only recalc counters, silently extending a loan // over a queued reservation). Only when the dates actually change. - if ($newPrestito !== (string) $current['data_prestito'] || $newScadenza !== (string) $current['data_scadenza']) { + // #336: check ONLY the newly-claimed segments — the EXACT set difference + // new window ∖ old window. Checking the WHOLE new window re-counted + // commitments that already coexist with the current period (e.g. a + // queued reservation overlapping the loan), so on a 1-copy book ANY + // date edit — even shortening the loan — bounced with + // no_copies_available. Days inside the old window (boundary days + // included: they are already held by this loan) need no re-check; + // only genuinely added days need free capacity. + // Old window from the LOCKED row, not the pre-transaction read. + $oldPrestito = (string) $locked['data_prestito']; + $oldScadenza = (string) $locked['data_scadenza']; + if ($newPrestito !== $oldPrestito || $newScadenza !== $oldScadenza) { + // Y-m-d strings compare correctly lexicographically (validated + // strict above); ±1 day via DateTimeImmutable, no TZ ambiguity. + $dayBefore = static fn (string $ymd): string => (new \DateTimeImmutable($ymd))->modify('-1 day')->format('Y-m-d'); + $dayAfter = static fn (string $ymd): string => (new \DateTimeImmutable($ymd))->modify('+1 day')->format('Y-m-d'); + $claimedWindows = []; + if ($newPrestito < $oldPrestito) { + $claimedWindows[] = [$newPrestito, min($dayBefore($oldPrestito), $newScadenza)]; + } + if ($newScadenza > $oldScadenza) { + $claimedWindows[] = [max($dayAfter($oldScadenza), $newPrestito), $newScadenza]; + } $capacity = new \App\Services\CapacityService($db); - if (!$capacity->hasFreeCapacity($libroId, $newPrestito, $newScadenza, excludePrestitoId: $id)) { - $db->rollback(); - return $response->withHeader('Location', url('/admin/loans') . '?error=no_copies_available')->withStatus(302); + foreach ($claimedWindows as [$claimStart, $claimEnd]) { + if (!$capacity->hasFreeCapacity($libroId, $claimStart, $claimEnd, excludePrestitoId: $id)) { + $db->rollback(); + return $response->withHeader('Location', url('/admin/loans') . '?error=no_copies_available')->withStatus(302); + } + } + + // CapacityService decides at BOOK level. With multiple copies it + // can report spare capacity even when the physical copy assigned + // to this loan has another future loan on the added days. The DB + // trigger would reject the UPDATE later, but only as the generic + // loan_update_failed. Mirror renew()/bulkExtend() here so the + // conflict is detected before the write and reported truthfully. + $copyId = $locked['copia_id'] !== null ? (int) $locked['copia_id'] : null; + if ($copyId !== null && $claimedWindows !== []) { + $copyOverlap = $db->prepare( + "SELECT 1 FROM prestiti + WHERE copia_id = ? AND id <> ? + AND data_prestito <= ? + AND (stato = 'in_ritardo' OR data_scadenza >= ?) + AND ((attivo = 1 AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')) + OR (attivo = 0 AND stato = 'pendente' AND copia_id IS NOT NULL)) + LIMIT 1" + ); + foreach ($claimedWindows as [$claimStart, $claimEnd]) { + $copyOverlap->bind_param('iiss', $copyId, $id, $claimEnd, $claimStart); + $copyOverlap->execute(); + if ((bool) $copyOverlap->get_result()->fetch_row()) { + $copyOverlap->close(); + $db->rollback(); + return $response->withHeader('Location', url('/admin/loans') . '?error=loan_copy_conflict')->withStatus(302); + } + } + $copyOverlap->close(); } } @@ -900,7 +1019,7 @@ public function update(Request $request, Response $response, mysqli $db, int $id // emails. A data_prestito-only edit does not affect the overdue clock, // so it is intentionally excluded from this guard (do not reuse the // combined data_prestito||data_scadenza condition above). - if ($newScadenza !== (string) $current['data_scadenza']) { + if ($newScadenza !== (string) $locked['data_scadenza']) { $today = \App\Support\DateHelper::today(); $recalcStato = $db->prepare( "UPDATE prestiti @@ -1065,6 +1184,7 @@ public function processReturn(Request $request, Response $response, mysqli $db, // LoanRepository::close()): la restituzione deve sempre poter procedere // anche su libro soft-deleted — la regola soft-delete governa // prestabilità/visibilità, non i rientri. + // CI-SOFT-DELETE-EXEMPT: a return must release its copy even when the book is deleted. $lockBook = $db->prepare('SELECT id FROM libri WHERE id = ? FOR UPDATE'); $lockBook->bind_param('i', $libro_id); $lockBook->execute(); @@ -1455,6 +1575,7 @@ public function bulkExtend(Request $request, Response $response, mysqli $db): Re $bookScan->close(); sort($bookIds, SORT_NUMERIC); + // CI-SOFT-DELETE-EXEMPT: bulk edits serialize existing loans even if a referenced book was deleted. $lockBook = $db->prepare('SELECT id FROM libri WHERE id = ? FOR UPDATE'); foreach ($bookIds as $bookId) { $lockBook->bind_param('i', $bookId); @@ -1571,17 +1692,22 @@ private function applyBulkLoanExtension( $todayDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $today); $base = ($todayDate !== false && $todayDate > $dueDate) ? $todayDate : $dueDate; $newDueDate = $base->modify('+' . $days . ' days')->format('Y-m-d'); - $loanStart = (string) $loan['data_prestito']; // Apply each accepted extension immediately inside the transaction, so the // next capacity check sees all earlier proposed extensions too. - if (!$capacity->hasFreeCapacity($bookId, $loanStart, $newDueDate, excludePrestitoId: $loanId)) { + // #336: BOTH gates check the same interval — only the days the extension + // actually adds (day after the current due date → new due date). The due + // date itself is already held by this loan, and the copy-overlap check + // previously scanned the whole loan window while capacity scanned the + // extension window: two different intervals for one decision (CodeRabbit). + $extensionStart = $dueDate->modify('+1 day')->format('Y-m-d'); + if (!$capacity->hasFreeCapacity($bookId, $extensionStart, $newDueDate, excludePrestitoId: $loanId)) { return null; } $copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null; if ($copyId !== null) { - $copyOverlap->bind_param('iiss', $copyId, $loanId, $newDueDate, $loanStart); + $copyOverlap->bind_param('iiss', $copyId, $loanId, $newDueDate, $extensionStart); $copyOverlap->execute(); if ((bool) $copyOverlap->get_result()->fetch_row()) { return null; @@ -1683,10 +1809,7 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) // Durata del rinnovo dalla setting di durata prestito (M5b): il vecchio // '+14 days' hardcoded ignorava la configurazione dell'admin. - $renewDays = (int) ($settingsRepo->get('loans', 'loan_duration_days', '30') ?? 30); - if ($renewDays < 1) { - $renewDays = 30; - } + $renewDays = $settingsRepo->loanDurationDays(); // Calculate proposed new due date for conflict checking $currentDueDate = $loan['data_scadenza']; @@ -1834,6 +1957,15 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) $db->commit(); $_SESSION['success_message'] = __('Prestito rinnovato correttamente. Nuova scadenza: %s', format_date($newDueDate, false, '/')); + // Conferma al lettore con la NUOVA scadenza, DOPO il commit: prima + // il rinnovo era l'unica transizione benefica senza email — se lo + // faceva il bibliotecario al banco, l'utente non lo sapeva proprio. + try { + (new \App\Support\NotificationService($db))->sendLoanRenewedNotification($id, $maxRenewals); + } catch (\Throwable $notifError) { + SecureLogger::warning(__('Notifica rinnovo prestito fallita'), ['loan_id' => $id, 'error' => $notifError->getMessage()]); + } + $successUrl = $redirectTo ?? url('/admin/loans'); $separator = strpos($successUrl, '?') === false ? '?' : '&'; return $response->withHeader('Location', url($successUrl . $separator . 'renewed=1'))->withStatus(302); diff --git a/app/Controllers/PublicApiController.php b/app/Controllers/PublicApiController.php index 3e97be948..cf6c96c86 100644 --- a/app/Controllers/PublicApiController.php +++ b/app/Controllers/PublicApiController.php @@ -100,7 +100,8 @@ private function findBooks(mysqli $db, ?string $ean, ?string $isbn13, ?string $i $whereClause = '(' . implode(' OR ', $conditions) . ') AND l.deleted_at IS NULL'; - // Main query to get books with all related data + // Main query to get books with all related data. + // CI-SOFT-DELETE-EXEMPT: $whereClause above unconditionally ends with l.deleted_at IS NULL. $sql = " SELECT l.id, diff --git a/app/Controllers/ReservationManager.php b/app/Controllers/ReservationManager.php index 7913085ea..ed5b1bf88 100644 --- a/app/Controllers/ReservationManager.php +++ b/app/Controllers/ReservationManager.php @@ -799,6 +799,7 @@ public function cancelExpiredReservations(): int return 0; } + // CI-SOFT-DELETE-EXEMPT: expiry cleanup must release queues belonging to deleted books too. $bookLock = $this->db->prepare('SELECT id FROM libri WHERE id = ? FOR UPDATE'); foreach ($affectedBooks as $bookId) { $bookLock->bind_param('i', $bookId); diff --git a/app/Controllers/ReservationsController.php b/app/Controllers/ReservationsController.php index 7d3214ce7..45c09350c 100644 --- a/app/Controllers/ReservationsController.php +++ b/app/Controllers/ReservationsController.php @@ -104,7 +104,11 @@ public function getBookAvailability($request, $response, $args) private function calculateAvailability($currentLoans, $existingReservations, int $totalCopies, ?string $startDate = null, int $days = 730, ?int $excludeUserId = null) { - $start = $startDate ? new DateTime($startDate) : new DateTime(); // today by default + // Default start = "today" in the APP timezone (DateHelper), not the PHP + // process TZ (usually UTC): a bare `new DateTime()` made the mobile + // calendar (the only null-start caller) begin on "yesterday" between + // midnight and 2am Rome time, diverging from every web surface. + $start = new DateTime($startDate ?: \App\Support\DateHelper::today()); $start->setTime(0, 0, 0); // Normalize intervals (#157, model A-refined): @@ -154,10 +158,18 @@ private function calculateAvailability($currentLoans, $existingReservations, int $loanIntervals[] = [$startDateLoan, $endDateLoan]; } + // F040: does the excluded user already hold an active reservation on this + // book? Same predicate as the date-less duplicate guard in + // createReservation (prenotazioni WHERE libro_id AND utente_id AND + // stato='attiva'). Surfaced so the picker can warn instead of showing an + // all-green calendar that the guard would reject for every date. + $hasActiveReservation = false; + $reservationIntervals = []; foreach ($existingReservations as $reservation) { // Skip reservation if it belongs to the excluded user (e.g. the user making the request) if ($excludeUserId !== null && isset($reservation['utente_id']) && (int) $reservation['utente_id'] === $excludeUserId) { + $hasActiveReservation = true; continue; } @@ -241,6 +253,7 @@ private function calculateAvailability($currentLoans, $existingReservations, int 'earliest_available' => $earliestAvailable, 'days' => $daysData, 'by_date' => array_column($daysData, null, 'date'), + 'has_active_reservation' => $hasActiveReservation, ]; } @@ -467,23 +480,56 @@ public function createReservation($request, $response, $args) $stmt->bind_param('iiss', $bookId, $userId, $startDate, $endDate); if ($stmt->execute()) { - $loanRequestId = $this->db->insert_id; + $loanRequestId = (int) $this->db->insert_id; $this->db->commit(); - // Send notification to admins - try { - $notificationService = new NotificationService($this->db); - $notificationService->notifyLoanRequest($loanRequestId); - } catch (\Throwable $notifError) { - \App\Support\SecureLogger::error('Error sending notification for loan request', ['error' => $notifError->getMessage()]); - // Don't fail the loan request creation if notification fails + // #301: honour the automatic-approval setting on THIS entry point + // too. The book-detail modal posts here, but the auto-approve + // lived only in UserActionsController::loan() — so real users' + // requests always landed in the admin approval queue even with + // the option enabled. Same race-safe canonical pipeline: a + // failure deliberately leaves the request pending for an admin. + // ?string: the persisted state ('prenotato' scheduled loan / + // 'da_ritirare' immediate pickup) on success, null when the + // request stays pending (setting off / approval failed). + $loanState = $this->autoApproveLoanRequest($request, $loanRequestId); + + if ($loanState === null) { + // Send notification to admins (an auto-approved request no + // longer needs admin action — the old "new request" email + // would carry a stale approval link). + try { + $notificationService = new NotificationService($this->db); + $notificationService->notifyLoanRequest($loanRequestId); + } catch (\Throwable $notifError) { + \App\Support\SecureLogger::error('Error sending notification for loan request', ['error' => $notifError->getMessage()]); + // Don't fail the loan request creation if notification fails + } + } + + // The message/status must describe the state actually persisted: + // a future-dated auto-approved loan is SCHEDULED ('prenotato'), + // not awaiting pickup. + if ($loanState === 'prenotato') { + $message = __('Prestito prenotato con successo'); + $status = 'scheduled'; + } elseif ($loanState === 'da_ritirare') { + $message = __('Prestito approvato - in attesa di ritiro'); + $status = 'approved'; + } else { + $message = __('Richiesta di prestito inviata con successo'); + $status = 'pending_approval'; } $response->getBody()->write(json_encode([ 'success' => true, - 'message' => __('Richiesta di prestito inviata con successo'), + 'message' => $message, 'loan_request_id' => $loanRequestId, - 'status' => 'pending_approval' + // Keep auto_approved a real boolean: book-detail.php compares + // it with === true. + 'auto_approved' => $loanState !== null, + 'status' => $status, + 'loan_state' => $loanState, ])); return $response->withHeader('Content-Type', 'application/json'); } else { @@ -501,8 +547,78 @@ public function createReservation($request, $response, $args) } } - public function getBookAvailabilityData($bookId, ?string $startDate = null, int $days = 730, ?int $excludeUserId = null) + /** + * Promote a newly-created request through the canonical approval pipeline + * when the automatic-approval setting is on (#301). Mirrors + * UserActionsController::autoApproveLoanRequest — a failure deliberately + * leaves the request pending so an administrator can still process it. + */ + private function autoApproveLoanRequest($request, int $loanId): ?string + { + // The settings read runs INSIDE the try: this helper is called AFTER the + // request is committed, so a DB hiccup in the SettingsRepository lookup + // must degrade to "left pending" (return null) rather than escape to the + // outer transaction catch, which would report a 500 for an already + // durable request and let the duplicate guard block the user's retry. + try { + $settings = new \App\Models\SettingsRepository($this->db); + if (!$settings->autoApproveLoanRequests()) { + // A disabled setting is not a failure: leave the request pending + // for an admin without logging any warning noise. + return null; + } + + $approvalRequest = $request + ->withParsedBody(['loan_id' => $loanId]) + ->withAttribute('automatic_loan_approval', true); + $result = (new \App\Controllers\LoanApprovalController())->approveLoan( + $approvalRequest, + new \Slim\Psr7\Response(), + $this->db + ); + + if ($result->getStatusCode() >= 200 && $result->getStatusCode() < 300) { + // Return the state approveLoan actually persisted ('prenotato' + // for a future-dated loan, 'da_ritirare' for an immediate one) + // so the response can describe the real outcome instead of + // assuming "awaiting pickup". + $body = json_decode((string) $result->getBody(), true); + return is_array($body) && isset($body['loan_state']) && is_string($body['loan_state']) + ? $body['loan_state'] + : 'da_ritirare'; + } + + \App\Support\SecureLogger::warning('Automatic loan approval left request pending (createReservation)', [ + 'loan_id' => $loanId, + 'status' => $result->getStatusCode(), + ]); + } catch (\Throwable $e) { + \App\Support\SecureLogger::warning('Automatic loan approval failed; request left pending (createReservation)', [ + 'loan_id' => $loanId, + 'error' => $e->getMessage(), + ]); + } + + return null; + } + + /** + * Per-day availability payload for a book, or NULL when the book does not + * exist or is soft-deleted. Every caller must 404 on null: without this + * guard the method served real per-day occupancy for soft-deleted books + * (libri queries MUST honour deleted_at IS NULL). + */ + public function getBookAvailabilityData($bookId, ?string $startDate = null, int $days = 730, ?int $excludeUserId = null): ?array { + $bookStmt = $this->db->prepare("SELECT id FROM libri WHERE id = ? AND deleted_at IS NULL"); + $bookStmt->bind_param('i', $bookId); + $bookStmt->execute(); + $bookExists = $bookStmt->get_result()->fetch_assoc() !== null; + $bookStmt->close(); + if (!$bookExists) { + return null; + } + $totalCopies = $this->getBookTotalCopies($bookId); // Get current and future loans for this book. Approved states always diff --git a/app/Controllers/ScrapeController.php b/app/Controllers/ScrapeController.php index 21022acc3..67be03208 100644 --- a/app/Controllers/ScrapeController.php +++ b/app/Controllers/ScrapeController.php @@ -12,6 +12,85 @@ class ScrapeController { + /** + * Deterministic scraper fixtures for browser CI. + * + * This is deliberately opt-in and is never enabled by application config: + * only the isolated CI virtual host sets PINAKES_E2E_SCRAPER_STUB=1. Keeping + * network providers out of regression tests prevents upstream outages and + * metadata changes from producing false failures or false passes. + */ + private function isE2eScraperStubEnabled(): bool + { + $flag = $_ENV['PINAKES_E2E_SCRAPER_STUB'] + ?? getenv('PINAKES_E2E_SCRAPER_STUB') + ?: ''; + return $flag === '1' || strtolower((string) $flag) === 'true'; + } + + /** @return array|null */ + private function e2eScraperStubPayload(string $identifier): ?array + { + $cover = '/uploads/copertine/placeholder.jpg'; + $fixtures = [ + '9780140328721' => [ + 'title' => 'Fantastic Mr. Fox', + 'authors' => ['Roald Dahl'], + 'publisher' => 'Puffin Books', + 'pubDate' => '2007', + 'year' => 2007, + 'image' => $cover, + 'source' => 'https://openlibrary.org', + '_primary_source' => 'open-library', + 'tipo_media' => 'libro', + 'format' => 'cartaceo', + 'isbn' => $identifier, + 'isbn13' => $identifier, + ], + '9788804671664' => [ + 'title' => 'E2E Italian catalogue fixture', + 'authors' => ['E2E Author'], + 'publisher' => 'E2E Editore', + 'pubDate' => '2016', + 'year' => 2016, + 'image' => $cover, + 'source' => 'https://openlibrary.org', + '_primary_source' => 'open-library', + 'tipo_media' => 'libro', + 'format' => 'cartaceo', + 'isbn' => $identifier, + 'isbn13' => $identifier, + 'classificazione_dewey' => '188', + ], + '0720642442524' => [ + 'title' => 'Nevermind', + 'authors' => ['Nirvana'], + 'publisher' => 'DGC', + 'pubDate' => '1991', + 'year' => 1991, + 'image' => $cover, + 'source' => 'discogs', + 'tipo_media' => 'disco', + 'format' => 'cd_audio', + 'ean' => $identifier, + ], + '5099902894225' => [ + 'title' => 'Meddle', + 'authors' => ['Pink Floyd'], + 'publisher' => 'Harvest', + 'pubDate' => '1971', + 'year' => 1971, + 'image' => $cover, + 'source' => 'discogs', + 'tipo_media' => 'disco', + 'format' => 'cd_audio', + 'ean' => $identifier, + ], + ]; + + return $fixtures[$identifier] ?? null; + } + /** * Normalize text by removing MARC-8 control characters and collapsing whitespace * MARC-8 uses NSB (0x88, 0x98) and NSE (0x89, 0x9C) for non-sorting blocks @@ -113,6 +192,17 @@ public function byIsbn(Request $request, Response $response): Response ], JSON_UNESCAPED_UNICODE)); return $response->withStatus(400)->withHeader('Content-Type', 'application/json'); } + + if ($this->isE2eScraperStubEnabled()) { + $stubPayload = $this->e2eScraperStubPayload($rawIdentifier); + if ($stubPayload !== null) { + $response->getBody()->write((string) json_encode( + $stubPayload, + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + )); + return $response->withHeader('Content-Type', 'application/json'); + } + } // SSRF Protection: Validate ISBN format before constructing URL $cleanIsbn = preg_replace('/[^0-9X]/', '', strtoupper($rawIdentifier)); diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index b7d9f4399..4cc0d72e1 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -1283,7 +1283,7 @@ public function updateEventSettings(Request $request, Response $response, mysqli } /** - * @return array{loan_duration_days: int, pickup_expiry_days: int, max_renewals: int, max_active_loans_per_user: int, max_loan_duration_days: int, auto_approve_requests: bool} + * @return array{loan_duration_days: int, pickup_expiry_days: int, max_renewals: int, max_active_loans_per_user: int, max_loan_duration_days: int, auto_approve_requests: bool, app_timezone: string} */ private function resolveLoansSettings(SettingsRepository $repository): array { @@ -1294,6 +1294,8 @@ private function resolveLoansSettings(SettingsRepository $repository): array 'max_active_loans_per_user' => (int) ($repository->get('loans', 'max_active_loans_per_user', '0') ?? 0), 'max_loan_duration_days' => (int) ($repository->get('loans', 'max_loan_duration_days', '90') ?? 90), 'auto_approve_requests' => $repository->autoApproveLoanRequests(), + // App-wide clock for due dates and automatisms (DateHelper reads it). + 'app_timezone' => (string) \App\Support\ConfigStore::get('app.timezone', 'Europe/Rome'), ]; } @@ -1328,6 +1330,17 @@ public function updateLoansSettings(Request $request, Response $response, mysqli $repository->set('loans', 'max_loan_duration_days', (string) $maxLoanDuration); $repository->set('loans', 'auto_approve_requests', $autoApprove ? '1' : '0'); + // App timezone: DateHelper computes the loan clock ("today"/"now") from + // this. Validate against the canonical identifier list — an invalid or + // missing value leaves the stored setting untouched (the DateHelper + // fallback ladder keeps working either way). + $timezone = isset($data['app_timezone']) && is_scalar($data['app_timezone']) + ? trim((string) $data['app_timezone']) + : ''; + if ($timezone !== '' && in_array($timezone, \DateTimeZone::listIdentifiers(), true)) { + \App\Support\ConfigStore::set('app.timezone', $timezone); + } + $_SESSION['success_message'] = __('Impostazioni prestiti aggiornate correttamente.'); return $response->withHeader('Location', url('/admin/settings?tab=loans'))->withStatus(302); } diff --git a/app/Controllers/UserActionsController.php b/app/Controllers/UserActionsController.php index b5be5d1d9..cc9f57ae6 100644 --- a/app/Controllers/UserActionsController.php +++ b/app/Controllers/UserActionsController.php @@ -71,14 +71,17 @@ public function reservationsPage(Request $request, Response $response, mysqli $d } $stmt->close(); - // Storico prestiti (ultimi 20) - solo prestiti conclusi + // Storico prestiti (ultimi 20) - tutti i prestiti conclusi, inclusi + // annullati e scaduti (prima sparivano dallo storico). Questi non hanno + // data_restituzione: ordina sul momento di chiusura (updated_at) così + // un annullamento recente non finisce in fondo alla lista. $sql = "SELECT pr.id, pr.libro_id, pr.data_prestito, pr.data_restituzione, pr.stato, l.titolo, l.copertina_url, EXISTS(SELECT 1 FROM recensioni r WHERE r.libro_id = pr.libro_id AND r.utente_id = ?) as has_review FROM prestiti pr JOIN libri l ON l.id = pr.libro_id AND l.deleted_at IS NULL - WHERE pr.utente_id = ? AND pr.attivo = 0 AND pr.stato IN ('restituito','perso','danneggiato') - ORDER BY pr.data_restituzione DESC, pr.data_prestito DESC + WHERE pr.utente_id = ? AND pr.attivo = 0 AND pr.stato IN ('restituito','perso','danneggiato','annullato','scaduto') + ORDER BY COALESCE(pr.data_restituzione, pr.updated_at) DESC, pr.data_prestito DESC LIMIT 20"; $stmt = $db->prepare($sql); $stmt->bind_param('ii', $uid, $uid); @@ -159,6 +162,7 @@ public function cancelLoan(Request $request, Response $response, mysqli $db): Re // Lock della riga libri per serializzare rilascio copia, promozione // coda e ricalcolo disponibilità con gli altri percorsi sullo stesso libro. + // CI-SOFT-DELETE-EXEMPT: user cancellation must release existing circulation state for a deleted book. $lockBookStmt = $db->prepare("SELECT id FROM libri WHERE id = ? FOR UPDATE"); $lockBookStmt->bind_param('i', $libroId); $lockBookStmt->execute(); @@ -218,6 +222,8 @@ public function cancelLoan(Request $request, Response $response, mysqli $db): Re // queued reservations. Loop until none convert. Both queues (D5/BUG10). $reservationManager = new \App\Controllers\ReservationManager($db); $reservationManager->setExternalTransaction(true); + // Reassignment and every queue promotion share this outer transaction: + // any exception reaches the catch below and rolls all mutations back. for ($promoGuard = 0; $promoGuard < 1000 && $reservationManager->processBookAvailability((int) $loan['libro_id']); $promoGuard++) { // keep promoting while freed capacity converts the next queued reservation } @@ -291,6 +297,7 @@ public function cancelReservation(Request $request, Response $response, mysqli $ // Lock the book row to serialize the queue reorder + availability // recalculation with other paths working on the same book's queue. + // CI-SOFT-DELETE-EXEMPT: reservation cancellation must unblock a deleted book's existing queue. $lockBookStmt = $db->prepare("SELECT id FROM libri WHERE id = ? FOR UPDATE"); $lockBookStmt->bind_param('i', $libroId); $lockBookStmt->execute(); @@ -605,6 +612,8 @@ public function loan(Request $request, Response $response, mysqli $db): Response // Promote immediately before performing slower email I/O, minimizing // the post-commit window in which another request could claim the // same copy. The canonical approval path re-checks every constraint. + // ?string: the persisted state ('prenotato'/'da_ritirare') on + // success, null when the request stays pending. $autoApproved = $this->autoApproveLoanRequest($request, $db, $newLoanId); // A successfully auto-approved request no longer needs admin action. @@ -623,6 +632,10 @@ public function loan(Request $request, Response $response, mysqli $db): Response 'loan_request_success' => 1, 'loan_id' => $newLoanId, 'auto_approved' => $autoApproved ? 1 : 0, + // Thread the persisted state through the redirect so the alert + // can distinguish a scheduled ('prenotato') loan from one + // awaiting pickup ('da_ritirare'). + 'loan_state' => $autoApproved ?? '', ]); } catch (\Throwable $e) { @@ -784,14 +797,21 @@ private function back(Response $response, array $params): Response * A failure deliberately leaves the request pending, so an administrator can * still process it instead of losing an otherwise valid request. */ - private function autoApproveLoanRequest(Request $request, mysqli $db, int $loanId): bool + private function autoApproveLoanRequest(Request $request, mysqli $db, int $loanId): ?string { - $settings = new \App\Models\SettingsRepository($db); - if (!$settings->autoApproveLoanRequests()) { - return false; - } - + // The settings read runs INSIDE the try: this helper is called AFTER the + // request is committed, so a DB hiccup in the SettingsRepository lookup + // must degrade to "left pending" (return null) rather than escape to the + // outer transaction catch, which would roll back and report a failure for + // an already durable request. try { + $settings = new \App\Models\SettingsRepository($db); + if (!$settings->autoApproveLoanRequests()) { + // A disabled setting is not a failure: leave the request pending + // for an admin without logging any warning noise. + return null; + } + $approvalRequest = $request ->withParsedBody(['loan_id' => $loanId]) ->withAttribute('automatic_loan_approval', true); @@ -802,7 +822,13 @@ private function autoApproveLoanRequest(Request $request, mysqli $db, int $loanI ); if ($result->getStatusCode() >= 200 && $result->getStatusCode() < 300) { - return true; + // Return the state approveLoan actually persisted ('prenotato' + // for a future-dated loan, 'da_ritirare' for an immediate one) + // so the caller can describe the real outcome. + $body = json_decode((string) $result->getBody(), true); + return is_array($body) && isset($body['loan_state']) && is_string($body['loan_state']) + ? $body['loan_state'] + : 'da_ritirare'; } SecureLogger::warning('Automatic loan approval left request pending', [ @@ -816,7 +842,7 @@ private function autoApproveLoanRequest(Request $request, mysqli $db, int $loanI ]); } - return false; + return null; } /** diff --git a/app/Controllers/UserDashboardController.php b/app/Controllers/UserDashboardController.php index c26e9e463..b708dab8e 100644 --- a/app/Controllers/UserDashboardController.php +++ b/app/Controllers/UserDashboardController.php @@ -45,8 +45,9 @@ public function index(Request $request, Response $response, mysqli $db): Respons $stats['preferiti'] = (int)($res->fetch_assoc()['c'] ?? 0); $stmt->close(); - // Count user loan history (exclude soft-deleted books) - $stmt = $db->prepare("SELECT COUNT(*) AS c FROM prestiti p JOIN libri l ON p.libro_id = l.id WHERE p.utente_id = ? AND p.attivo = 0 AND p.stato IN ('restituito','perso','danneggiato') AND l.deleted_at IS NULL"); + // Count user loan history (exclude soft-deleted books) — includes + // cancelled/expired loans, same predicate as the history list below. + $stmt = $db->prepare("SELECT COUNT(*) AS c FROM prestiti p JOIN libri l ON p.libro_id = l.id WHERE p.utente_id = ? AND p.attivo = 0 AND p.stato IN ('restituito','perso','danneggiato','annullato','scaduto') AND l.deleted_at IS NULL"); $stmt->bind_param('i', $userId); $stmt->execute(); $res = $stmt->get_result(); @@ -181,15 +182,17 @@ public function prenotazioni(Request $request, Response $response, mysqli $db, m } $stmt->close(); - // Past loans (completed) + // Past loans (completed) — includes cancelled/expired loans, which have + // no data_restituzione: order on the closing moment (updated_at) so a + // recent cancellation doesn't sink to the bottom of the list. $stmt = $db->prepare(" SELECT pr.id, pr.libro_id, pr.data_prestito, pr.data_restituzione, pr.stato, l.titolo, l.copertina_url, EXISTS(SELECT 1 FROM recensioni r WHERE r.libro_id = pr.libro_id AND r.utente_id = ?) AS has_review FROM prestiti pr JOIN libri l ON l.id = pr.libro_id - WHERE pr.utente_id = ? AND pr.attivo = 0 AND pr.stato IN ('restituito','perso','danneggiato') AND l.deleted_at IS NULL - ORDER BY pr.data_restituzione DESC, pr.data_prestito DESC + WHERE pr.utente_id = ? AND pr.attivo = 0 AND pr.stato IN ('restituito','perso','danneggiato','annullato','scaduto') AND l.deleted_at IS NULL + ORDER BY COALESCE(pr.data_restituzione, pr.updated_at) DESC, pr.data_prestito DESC LIMIT 50 "); $stmt->bind_param('ii', $userId, $userId); diff --git a/app/Models/BookRepository.php b/app/Models/BookRepository.php index 486f7f2a8..b88d15536 100644 --- a/app/Models/BookRepository.php +++ b/app/Models/BookRepository.php @@ -81,6 +81,7 @@ public function getById(int $id): ?array $sql .= ", NULL AS gruppo_serie, NULL AS ciclo_serie, NULL AS ordine_ciclo, NULL AS tipo_collana, NULL AS serie_padre"; } + // CI-SOFT-DELETE-EXEMPT: this query fragment receives WHERE l.id=? AND l.deleted_at IS NULL before prepare below. $sql .= ", p.id AS posizione_id_join, m.numero_livello AS mensola_livello, s.codice AS scaffale_codice, diff --git a/app/Models/CopyRepository.php b/app/Models/CopyRepository.php index d0d298585..1b6205417 100644 --- a/app/Models/CopyRepository.php +++ b/app/Models/CopyRepository.php @@ -14,6 +14,36 @@ public function __construct(mysqli $db) $this->db = $db; } + /** + * Sort physical-copy rows by inventory code using human/natural ordering. + * VARCHAR ordering puts C10 before C2; labels and the admin table must instead + * follow the sequence staff see on the physical copies (#238). + * + * @param array> $copies + * @return array> + */ + public static function sortByInventoryNumber(array $copies): array + { + usort($copies, static function (array $left, array $right): int { + $leftCode = (string) ($left['numero_inventario'] ?? ''); + $rightCode = (string) ($right['numero_inventario'] ?? ''); + $comparison = strnatcasecmp($leftCode, $rightCode); + if ($comparison !== 0) { + return $comparison; + } + + // Make ties deterministic across collations/case variants and repeated + // calls, even though inventory codes are normally globally unique. + $comparison = strcmp($leftCode, $rightCode); + if ($comparison !== 0) { + return $comparison; + } + return ((int) ($left['id'] ?? 0)) <=> ((int) ($right['id'] ?? 0)); + }); + + return $copies; + } + /** * Ottiene tutte le copie di un libro */ @@ -48,6 +78,7 @@ public function getByBookId(int $bookId): array } $stmt->close(); + $copie = self::sortByInventoryNumber($copie); $copyIndexes = []; foreach ($copie as $index => $copy) { diff --git a/app/Models/DashboardStats.php b/app/Models/DashboardStats.php index 4133595c2..dd650d35d 100644 --- a/app/Models/DashboardStats.php +++ b/app/Models/DashboardStats.php @@ -14,15 +14,21 @@ public function counts(): array { $db = $this->db; return QueryCache::remember('dashboard_counts', function () use ($db): array { + // "Oggi" nel timezone APPLICATIVO (DateHelper), non la funzione data + // del server MySQL: il cron attiva i prestiti con DateHelper::today() e questa + // stessa card deve contare gli stessi ritiri pronti — con due orologi + // diversi, a cavallo della mezzanotte i due conteggi divergevano. + // Y-m-d validato da DateHelper: interpolazione sicura tra apici. + $today = \App\Support\DateHelper::today(); $sql = "SELECT (SELECT COUNT(*) FROM libri WHERE deleted_at IS NULL) AS libri, (SELECT COUNT(*) FROM utenti) AS utenti, - (SELECT COUNT(*) FROM prestiti WHERE stato IN ('in_corso','in_ritardo') AND attivo = 1) AS prestiti_in_corso, + (SELECT COUNT(*) FROM prestiti p JOIN libri l ON l.id = p.libro_id AND l.deleted_at IS NULL WHERE p.stato IN ('in_corso','in_ritardo') AND p.attivo = 1) AS prestiti_in_corso, (SELECT COUNT(*) FROM autori) AS autori, - (SELECT COUNT(*) FROM prestiti WHERE stato = 'pendente') AS prestiti_pendenti, - (SELECT COUNT(*) FROM prestiti WHERE stato = 'pendente' AND origine = 'prenotazione') AS ritiri_da_confermare, - (SELECT COUNT(*) FROM prestiti WHERE stato = 'pendente' AND (origine = 'richiesta' OR origine IS NULL)) AS richieste_manuali, - (SELECT COUNT(*) FROM prestiti WHERE stato = 'da_ritirare' OR (stato = 'prenotato' AND data_prestito <= CURDATE())) AS pickup_pronti"; + (SELECT COUNT(*) FROM prestiti p JOIN libri l ON l.id = p.libro_id AND l.deleted_at IS NULL WHERE p.stato = 'pendente') AS prestiti_pendenti, + (SELECT COUNT(*) FROM prestiti p JOIN libri l ON l.id = p.libro_id AND l.deleted_at IS NULL WHERE p.stato = 'pendente' AND p.origine = 'prenotazione') AS ritiri_da_confermare, + (SELECT COUNT(*) FROM prestiti p JOIN libri l ON l.id = p.libro_id AND l.deleted_at IS NULL WHERE p.stato = 'pendente' AND (p.origine = 'richiesta' OR p.origine IS NULL)) AS richieste_manuali, + (SELECT COUNT(*) FROM prestiti p JOIN libri l ON l.id = p.libro_id AND l.deleted_at IS NULL WHERE (p.stato = 'da_ritirare' OR (p.stato = 'prenotato' AND p.data_prestito <= '{$today}'))) AS pickup_pronti"; $result = $db->query($sql); if ($result && $row = $result->fetch_assoc()) { @@ -127,7 +133,7 @@ public function pendingLoans(int $limit = 4): array public function pickupReadyLoans(int $limit = 6): array { $rows = []; - $today = date('Y-m-d'); + $today = \App\Support\DateHelper::today(); $sql = "SELECT p.id, p.libro_id, p.utente_id, p.stato, p.data_prestito, p.data_scadenza, p.pickup_deadline, p.created_at, l.titolo, l.copertina_url, @@ -155,7 +161,7 @@ public function pickupReadyLoans(int $limit = 6): array public function scheduledLoans(int $limit = 6): array { $rows = []; - $today = date('Y-m-d'); + $today = \App\Support\DateHelper::today(); $sql = "SELECT p.id, p.libro_id, p.utente_id, p.stato, p.data_prestito, p.data_scadenza, p.created_at, l.titolo, l.copertina_url, diff --git a/app/Models/GenereRepository.php b/app/Models/GenereRepository.php index 9bc16652b..ce0de6557 100644 --- a/app/Models/GenereRepository.php +++ b/app/Models/GenereRepository.php @@ -511,6 +511,7 @@ public function merge(int $sourceId, int $targetId): array } // Count distinct books referencing source (including soft-deleted, since we delete the genre row) + // CI-SOFT-DELETE-EXEMPT: genre merges must include deleted books to avoid dangling foreign keys after restore. $stmt = $this->db->prepare("SELECT COUNT(DISTINCT id) as cnt FROM libri WHERE genere_id = ? OR sottogenere_id = ?"); $stmt->bind_param('ii', $sourceId, $sourceId); if (!$stmt->execute()) { diff --git a/app/Models/LoanRepository.php b/app/Models/LoanRepository.php index 1446edecf..ab16783b5 100644 --- a/app/Models/LoanRepository.php +++ b/app/Models/LoanRepository.php @@ -93,7 +93,15 @@ public function update(int $id, array $data): bool $utente_id = (int) ($data['utente_id'] ?? 0); // "Oggi" nel timezone applicativo (M9): mai date() (TZ processo, spesso UTC). $data_prestito = $data['data_prestito'] ?? DateHelper::today(); - $data_scadenza = $data['data_scadenza'] ?? date('Y-m-d', strtotime(DateHelper::today() . ' +14 days')); + if (isset($data['data_scadenza'])) { + $data_scadenza = $data['data_scadenza']; + } else { + // Fallback dalla setting di durata prestito: il vecchio +14gg + // hardcoded era metà del default seminato (30) e ignorava la + // configurazione dell'admin — stesso fix M5b già applicato a renew(). + $loanDays = (new SettingsRepository($this->db))->loanDurationDays(); + $data_scadenza = date('Y-m-d', strtotime($data_prestito . " +{$loanDays} days")); + } $processed_by = $data['processed_by'] ?? null; $stmt->bind_param('issii', $utente_id, $data_prestito, $data_scadenza, $processed_by, $id); return $stmt->execute(); @@ -165,6 +173,7 @@ public function close(int $id): bool // il libro è stato soft-deleted nel frattempo: la regola soft-delete // governa prestabilità/visibilità, non i rientri. Bloccare il close // lascerebbe il prestito attivo e la copia occupata per sempre. + // CI-SOFT-DELETE-EXEMPT: closing a loan must free its copy even after the book was deleted. $lockBook = $this->db->prepare('SELECT id FROM libri WHERE id=? FOR UPDATE'); $lockBook->bind_param('i', $bookId); $lockBook->execute(); diff --git a/app/Models/SeriesRepository.php b/app/Models/SeriesRepository.php index 2ad2259bd..5c941694b 100644 --- a/app/Models/SeriesRepository.php +++ b/app/Models/SeriesRepository.php @@ -65,6 +65,11 @@ public function supportsMemberships(): bool return $this->tableExists('libri_collane') && $this->hasCollaneTable(); } + public function supportsCompleteFlag(): bool + { + return $this->hasColumn('collane', 'is_completa'); + } + public function ensureCollana(string $nome, array $metadata = [], bool $updateMetadata = true): ?int { $nome = $this->cleanName($nome); @@ -348,11 +353,14 @@ public function listSeries(): array $selectMeta = $this->supportsHierarchy() ? 'c.tipo, c.parent_id, p.nome AS parent_nome, c.gruppo_serie, c.ciclo, c.ordine_ciclo' : 'NULL AS tipo, NULL AS parent_id, NULL AS parent_nome, NULL AS gruppo_serie, NULL AS ciclo, NULL AS ordine_ciclo'; + $selectComplete = $this->supportsCompleteFlag() ? 'c.is_completa' : '0 AS is_completa'; + $groupComplete = $this->supportsCompleteFlag() ? ', c.is_completa' : ''; if ($this->supportsMemberships()) { $sql = " SELECT c.nome AS collana, {$selectMeta}, + {$selectComplete}, COUNT(DISTINCT m.libro_id) AS book_count, MIN(CASE WHEN TRIM(m.numero_serie) REGEXP '^[0-9]+$' THEN CAST(m.numero_serie AS UNSIGNED) END) AS min_num, MAX(CASE WHEN TRIM(m.numero_serie) REGEXP '^[0-9]+$' THEN CAST(m.numero_serie AS UNSIGNED) END) AS max_num @@ -368,20 +376,21 @@ public function listSeries(): array JOIN collane c2 ON c2.nome = l.collana WHERE l.collana IS NOT NULL AND l.collana != '' AND l.deleted_at IS NULL ) m ON m.collana_id = c.id - GROUP BY c.id, c.nome" . ($this->supportsHierarchy() ? ', c.tipo, c.parent_id, p.nome, c.gruppo_serie, c.ciclo, c.ordine_ciclo' : '') . " + GROUP BY c.id, c.nome" . ($this->supportsHierarchy() ? ', c.tipo, c.parent_id, p.nome, c.gruppo_serie, c.ciclo, c.ordine_ciclo' : '') . $groupComplete . " ORDER BY " . $this->seriesOrderClause('c') . " "; } else { $sql = " SELECT c.nome AS collana, {$selectMeta}, + {$selectComplete}, COUNT(l.id) AS book_count, MIN(CASE WHEN TRIM(l.numero_serie) REGEXP '^[0-9]+$' THEN CAST(l.numero_serie AS UNSIGNED) END) AS min_num, MAX(CASE WHEN TRIM(l.numero_serie) REGEXP '^[0-9]+$' THEN CAST(l.numero_serie AS UNSIGNED) END) AS max_num FROM collane c " . ($this->supportsHierarchy() ? 'LEFT JOIN collane p ON p.id = c.parent_id' : '') . " LEFT JOIN libri l ON l.collana = c.nome AND l.deleted_at IS NULL - GROUP BY c.id, c.nome" . ($this->supportsHierarchy() ? ', c.tipo, c.parent_id, p.nome, c.gruppo_serie, c.ciclo, c.ordine_ciclo' : '') . " + GROUP BY c.id, c.nome" . ($this->supportsHierarchy() ? ', c.tipo, c.parent_id, p.nome, c.gruppo_serie, c.ciclo, c.ordine_ciclo' : '') . $groupComplete . " ORDER BY " . $this->seriesOrderClause('c') . " "; } @@ -396,9 +405,10 @@ public function getSeriesByName(string $name): ?array } $name = $this->cleanName($name); + $completeFallback = $this->supportsCompleteFlag() ? '' : ', 0 AS is_completa'; $selectMeta = $this->supportsHierarchy() - ? 'c.*, p.nome AS parent_nome' - : 'c.*, NULL AS parent_nome, NULL AS parent_id, NULL AS tipo, NULL AS gruppo_serie, NULL AS ciclo, NULL AS ordine_ciclo'; + ? 'c.*, p.nome AS parent_nome' . $completeFallback + : 'c.*, NULL AS parent_nome, NULL AS parent_id, NULL AS tipo, NULL AS gruppo_serie, NULL AS ciclo, NULL AS ordine_ciclo' . $completeFallback; $join = $this->supportsHierarchy() ? 'LEFT JOIN collane p ON p.id = c.parent_id' : ''; $stmt = $this->db->prepare("SELECT {$selectMeta} FROM collane c {$join} WHERE c.nome = ? LIMIT 1"); if (!$stmt) { @@ -1037,6 +1047,11 @@ private function updateCollanaMetadata(int $id, string $nome, array $metadata): $types .= 's'; $params[] = $metadata['descrizione']; } + if ($this->hasColumn('collane', 'is_completa') && array_key_exists('is_completa', $metadata)) { + $sets[] = 'is_completa = ?'; + $types .= 'i'; + $params[] = !empty($metadata['is_completa']) ? 1 : 0; + } if ($sets === []) { return; @@ -1258,7 +1273,7 @@ private function legacySeriesList(): array { $sql = " SELECT collana, NULL AS tipo, NULL AS parent_id, NULL AS parent_nome, - NULL AS gruppo_serie, NULL AS ciclo, NULL AS ordine_ciclo, + NULL AS gruppo_serie, NULL AS ciclo, NULL AS ordine_ciclo, 0 AS is_completa, COUNT(*) AS book_count, MIN(CASE WHEN TRIM(numero_serie) REGEXP '^[0-9]+$' THEN CAST(numero_serie AS UNSIGNED) END) AS min_num, MAX(CASE WHEN TRIM(numero_serie) REGEXP '^[0-9]+$' THEN CAST(numero_serie AS UNSIGNED) END) AS max_num diff --git a/app/Models/SettingsRepository.php b/app/Models/SettingsRepository.php index 6b0b82cf8..577198479 100644 --- a/app/Models/SettingsRepository.php +++ b/app/Models/SettingsRepository.php @@ -97,6 +97,16 @@ public function autoApproveLoanRequests(): bool return ($this->get('loans', 'auto_approve_requests', '0') ?? '0') === '1'; } + /** + * Configured default loan duration. Invalid or missing values retain the + * historical 30-day fallback in every loan creation/update path. + */ + public function loanDurationDays(): int + { + $days = (int) ($this->get('loans', 'loan_duration_days', '30') ?? 30); + return $days >= 1 ? $days : 30; + } + /** * @return array */ diff --git a/app/Routes/web.php b/app/Routes/web.php index df83d7ba3..bb8034823 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -1962,10 +1962,18 @@ return $response->withStatus(404)->withHeader('Content-Type', 'application/json'); } $data['available'] = ($data['copies_available'] > 0); - // Next due date among active loans + // Next due date among HOLDING loans, never in the past. The bare + // `attivo = 1` version could return the past data_scadenza of an + // in_ritardo loan ("available again on ") or the + // far-future date of a scheduled prenotato — align the predicate with + // NotificationService::getNextAvailabilityDate. if (!$data['available']) { - $stmt = $db->prepare("SELECT MIN(data_scadenza) AS next_due FROM prestiti WHERE libro_id = ? AND attivo = 1"); - $stmt->bind_param('i', $bookId); + $today = \App\Support\DateHelper::today(); + $stmt = $db->prepare("SELECT MIN(data_scadenza) AS next_due FROM prestiti + WHERE libro_id = ? AND attivo = 1 + AND stato IN ('in_corso','in_ritardo','da_ritirare','prenotato') + AND data_scadenza >= ?"); + $stmt->bind_param('is', $bookId, $today); $stmt->execute(); $res = $stmt->get_result(); $row = $res->fetch_assoc(); @@ -1993,7 +2001,27 @@ if ($days > 180) $days = 180; $controller = new \App\Controllers\ReservationsController($db); - $availability = $controller->getBookAvailabilityData($bookId, \App\Support\DateHelper::today(), $days); + // Exclude the requesting user's own reservations, like the mobile + // calendar and the server-side write gate already do: without it the + // picker painted the user's own reserved days red while the server + // would have accepted the same dates. + // F012: the admin loan form fetches this same route while creating a loan + // FOR a borrower who is NOT the session operator. When an admin/staff + // passes ?for_user=, exclude THAT borrower's reservations instead of + // the operator's, so the calendar matches the write gate. Anonymous or + // non-privileged callers stay on the session id (self-service default). + $sessionUserId = isset($_SESSION['user']['id']) ? (int) $_SESSION['user']['id'] : null; + $forUser = $request->getQueryParams()['for_user'] ?? null; + $sessionRole = $_SESSION['user']['tipo_utente'] ?? ''; + $excludeUserId = $sessionUserId; + if ($forUser !== null && is_numeric($forUser) && in_array($sessionRole, ['admin', 'staff'], true)) { + $excludeUserId = (int) $forUser; + } + $availability = $controller->getBookAvailabilityData($bookId, \App\Support\DateHelper::today(), $days, $excludeUserId); + if ($availability === null) { + $response->getBody()->write(json_encode(['success' => false, 'message' => __('Libro non trovato')])); + return $response->withStatus(404)->withHeader('Content-Type', 'application/json'); + } $response->getBody()->write(json_encode([ 'total_copies' => $availability['total_copies'] ?? 0, @@ -2162,11 +2190,21 @@ return $response->withStatus(404)->withHeader('Content-Type', 'application/json'); } - // Intervalli occupati (per la visualizzazione) dai prestiti attivi + // Intervalli occupati (per la visualizzazione). Predicato HOLDING completo + // (#157): oltre agli stati attivi, anche il 'pendente' da conversione + // prenotazione che detiene già una copia. Senza questi due archi il + // payload si contraddiceva: occupied_ranges diceva "libero" mentre + // first_available/is_available_now (calcolati per-giorno qui sotto) + // contavano anche pendenti-con-copia e coda prenotazioni. $stmt = $db->prepare(" - SELECT data_prestito, data_scadenza, stato + SELECT data_prestito, + CASE WHEN stato = 'in_ritardo' THEN '9999-12-31' ELSE data_scadenza END AS occupied_until, + stato FROM prestiti - WHERE libro_id = ? AND attivo = 1 AND stato IN ('in_corso', 'da_ritirare', 'prenotato', 'in_ritardo') + WHERE libro_id = ? AND ( + (attivo = 1 AND stato IN ('in_corso', 'da_ritirare', 'prenotato', 'in_ritardo')) + OR (attivo = 0 AND stato = 'pendente' AND copia_id IS NOT NULL) + ) ORDER BY data_prestito "); $stmt->bind_param('i', $libroId); @@ -2176,18 +2214,50 @@ while ($row = $result->fetch_assoc()) { $occupiedRanges[] = [ 'from' => $row['data_prestito'], - 'to' => $row['data_scadenza'], + // An overdue, unreturned copy has no known release date. Keep + // this display payload aligned with CapacityService, which + // treats it as occupied for every future availability day. + 'to' => $row['occupied_until'], 'stato' => $row['stato'] ]; } $stmt->close(); + // Anche la coda prenotazioni occupa il suo periodo promesso (stessa + // regola di CapacityService): catena COALESCE canonica per i bound. + $resStmt = $db->prepare(" + SELECT COALESCE(data_inizio_richiesta, DATE(data_scadenza_prenotazione)) AS r_start, + COALESCE(data_fine_richiesta, DATE(data_scadenza_prenotazione), data_inizio_richiesta) AS r_end + FROM prenotazioni + WHERE libro_id = ? AND stato = 'attiva' + ORDER BY queue_position ASC + "); + $resStmt->bind_param('i', $libroId); + $resStmt->execute(); + $resResult = $resStmt->get_result(); + while ($row = $resResult->fetch_assoc()) { + if (!empty($row['r_start'])) { + $occupiedRanges[] = [ + 'from' => $row['r_start'], + 'to' => $row['r_end'] ?? $row['r_start'], + 'stato' => 'prenotazione' + ]; + } + } + $resStmt->close(); + // first_available / is_available_now: delega al calcolo per-giorno e per-copia // (AVAIL-001). Il vecchio "giorno dopo la scadenza più lontana" ignorava le // copie multiple, restituendo una data troppo conservativa. $today = \App\Support\DateHelper::today(); $reservations = new \App\Controllers\ReservationsController($db); $availability = $reservations->getBookAvailabilityData($libroId, $today, 180); + if ($availability === null) { + // The book was soft-deleted after the initial lookup above. Keep the + // nullable provider contract consistent with the sibling endpoints. + $response->getBody()->write(json_encode(['success' => false, 'message' => __('Libro non trovato')])); + return $response->withStatus(404)->withHeader('Content-Type', 'application/json'); + } $todayData = $availability['by_date'][$today] ?? null; $isAvailableNow = $todayData !== null && (int) ($todayData['available'] ?? 0) > 0; @@ -2465,13 +2535,35 @@ $db = $app->getContainer()->get('db'); $bookId = (int)$args['id']; $controller = new \App\Controllers\ReservationsController($db); - $availability = $controller->getBookAvailabilityData($bookId, \App\Support\DateHelper::today(), 180); + // Public endpoint (the book-page picker) — when a session exists, + // exclude the user's own reservations like the write gate does. + // F012: the admin loan form fetches this same route to create a loan + // FOR a borrower who is not the session operator. An admin/staff can + // pass ?for_user= to exclude THAT borrower instead of the + // operator; everyone else stays on the session id (self-service). + $sessionUserId = isset($_SESSION['user']['id']) ? (int) $_SESSION['user']['id'] : null; + $forUser = $request->getQueryParams()['for_user'] ?? null; + $sessionRole = $_SESSION['user']['tipo_utente'] ?? ''; + $excludeUserId = $sessionUserId; + if ($forUser !== null && is_numeric($forUser) && in_array($sessionRole, ['admin', 'staff'], true)) { + $excludeUserId = (int) $forUser; + } + $availability = $controller->getBookAvailabilityData($bookId, \App\Support\DateHelper::today(), 180, $excludeUserId); + if ($availability === null) { + $response->getBody()->write(json_encode(['success' => false, 'message' => __('Libro non trovato')])); + return $response->withStatus(404)->withHeader('Content-Type', 'application/json'); + } $data = [ 'success' => true, 'availability' => [ 'unavailable_dates' => $availability['unavailable_dates'] ?? [], - 'earliest_available' => $availability['earliest_available'] ?? date('Y-m-d'), - 'days' => $availability['days'] ?? [] + 'earliest_available' => $availability['earliest_available'] ?? \App\Support\DateHelper::today(), + 'days' => $availability['days'] ?? [], + // F040: true when the excluded user already holds an active + // reservation on this book — the picker would otherwise show + // an all-green calendar that the date-less duplicate guard in + // createReservation rejects for every date. + 'has_active_reservation' => $availability['has_active_reservation'] ?? false ] ]; $response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE)); diff --git a/app/Services/ReservationReassignmentService.php b/app/Services/ReservationReassignmentService.php index fdde749b2..3b61547ad 100644 --- a/app/Services/ReservationReassignmentService.php +++ b/app/Services/ReservationReassignmentService.php @@ -161,6 +161,7 @@ public function reassignOnNewCopy(int $libroId, int $newCopiaId): void // 2. Se abbiamo trovato una prenotazione da sbloccare, proviamo ad assegnarla alla nuova copia $ownTransaction = $this->beginTransactionIfNeeded(); try { + // CI-SOFT-DELETE-EXEMPT: an existing hold must be released/reassigned even if its book is deleted. $lockBook = $this->db->prepare('SELECT id FROM libri WHERE id = ? FOR UPDATE'); $lockBook->bind_param('i', $libroId); $lockBook->execute(); @@ -318,6 +319,7 @@ public function reassignOnCopyLost(int $copiaId): void // Riassegna $ownTransaction = $this->beginTransactionIfNeeded(); try { + // CI-SOFT-DELETE-EXEMPT: retrying an existing hold must serialize a deleted book's circulation rows. $lockBook = $this->db->prepare('SELECT id FROM libri WHERE id = ? FOR UPDATE'); $lockBook->bind_param('i', $libroId); $lockBook->execute(); @@ -443,6 +445,7 @@ private function handleNoCopyAvailable(int $reservationId): void $ownTransaction = $this->beginTransactionIfNeeded(); try { + // CI-SOFT-DELETE-EXEMPT: releasing an unassignable hold must work for a deleted book. $lockBook = $this->db->prepare('SELECT id FROM libri WHERE id = ? FOR UPDATE'); $lockBook->bind_param('i', $libroId); $lockBook->execute(); diff --git a/app/Support/ConfigStore.php b/app/Support/ConfigStore.php index 212e14acb..b2161e531 100644 --- a/app/Support/ConfigStore.php +++ b/app/Support/ConfigStore.php @@ -36,6 +36,12 @@ public static function all(): array 'logo' => '', 'footer_description' => 'Il tuo sistema Pinakes per catalogare, gestire e condividere la tua collezione libraria.', 'locale' => 'it_IT', + // App-wide timezone: DateHelper::today()/now() compute the loan + // clock from this. Was a phantom key (read but defined nowhere, + // always resolving to the hardcoded fallback) — now a real + // default, seeded per-locale by the installer and editable from + // the loans settings tab. + 'timezone' => 'Europe/Rome', 'social_facebook' => '', 'social_twitter' => '', 'social_instagram' => '', @@ -419,6 +425,14 @@ private static function loadDatabaseSettings(): array if (isset($raw['app']['locale'])) { self::$dbSettingsCache['app']['locale'] = (string) $raw['app']['locale']; } + // Load timezone (loan clock — DateHelper reads app.timezone). + // This mapping is what makes the setting REAL: without it the + // installer seed and the loans-tab save wrote a row that was + // never read back, and get('app.timezone') always returned the + // hardcoded default (caught by the adversarial review). + if (isset($raw['app']['timezone']) && $raw['app']['timezone'] !== '') { + self::$dbSettingsCache['app']['timezone'] = (string) $raw['app']['timezone']; + } // Load social links $socialKeys = ['social_facebook', 'social_twitter', 'social_instagram', 'social_linkedin', 'social_bluesky', 'social_telegram']; foreach ($socialKeys as $socialKey) { diff --git a/app/Support/ContentSecurityPolicy.php b/app/Support/ContentSecurityPolicy.php new file mode 100644 index 000000000..7a2c1a252 --- /dev/null +++ b/app/Support/ContentSecurityPolicy.php @@ -0,0 +1,83 @@ + or ' + . ''; +$rewritten = ContentSecurityPolicy::addNonceAttributes($html, $nonce); + +$check((bool) preg_match('/^[a-f0-9]{32}$/', $nonce), 'nonce is cryptographically sized and CSP-safe'); +$check(str_contains($header, "script-src 'self' 'nonce-{$nonce}'"), 'script elements require the response nonce'); +$check(str_contains($header, "style-src 'self' 'nonce-{$nonce}'"), 'style elements require the response nonce'); +$check(!str_contains($header, "script-src 'self' 'unsafe-inline'"), 'script-src does not permit arbitrary inline scripts'); +$check(!str_contains($header, "style-src 'self' 'unsafe-inline'"), 'style-src does not permit arbitrary inline stylesheets'); +$check(!preg_match('/(?:img|script|style)-src[^;]*(?:https?:|\*)\s*(?:;|$)/', $header), 'source directives contain no scheme-wide or star wildcard'); +$check(str_contains($header, "object-src 'none'"), 'object embedding is denied'); +$check(str_contains($header, "base-uri 'self'") && str_contains($header, "form-action 'self'"), 'no-fallback navigation directives are explicit'); +$check(substr_count($rewritten, 'nonce="' . $nonce . '"') === 2, 'nonce is attached to every untrusted script/style element'); +$check(substr_count($rewritten, 'nonce="0123456789abcdef0123456789abcdef"') === 1, 'an existing nonce is never overwritten'); +$check(str_ends_with(ContentSecurityPolicy::header($nonce, true), '; upgrade-insecure-requests'), 'HTTPS production policy upgrades insecure requests'); +$check(ContentSecurityPolicy::isHtmlResponse('', "\n"), 'legacy HTML without Content-Type is detected'); +$check(ContentSecurityPolicy::isHtmlResponse('text/html; charset=UTF-8', ''), 'declared HTML is detected'); +$check(!ContentSecurityPolicy::isHtmlResponse('application/json', '{"script":"