Skip to content

feat(interop): v0.7.5 — French locale (fr_FR), BNF SRU/UNIMARC, all interop plugins (OAI-PMH, NCIP, Z39.50, VIAF, BIBFRAME, ResourceSync)#132

Open
fabiodalez-dev wants to merge 99 commits into
mainfrom
feat/fr-bnf-integration
Open

feat(interop): v0.7.5 — French locale (fr_FR), BNF SRU/UNIMARC, all interop plugins (OAI-PMH, NCIP, Z39.50, VIAF, BIBFRAME, ResourceSync)#132
fabiodalez-dev wants to merge 99 commits into
mainfrom
feat/fr-bnf-integration

Conversation

@fabiodalez-dev
Copy link
Copy Markdown
Owner

Summary

  • French locale (fr_FR): full translation (4145 keys, 100%), BNF SRU scraping with UNIMARC mapping, locale routes for French
  • Migration fixes: migrate_0.7.5.sql uses ON DUPLICATE KEY UPDATE to activate fr_FR on upgrades (was INSERT IGNORE); Language::setDefault() now auto-activates the language to prevent is_default=1, is_active=0 inconsistency
  • Dev-schema guard in migrate_0.7.0.sql: drops and recreates author_authority_alternates if old pre-release dev columns (source_code) are detected — fixes upgrade failure on installations that had dev migrations applied manually
  • de_DE routes: added missing bibframe.book key to routes_de_DE.json (parity with it_IT, en_US, fr_FR)
  • Interop plugins: OAI-PMH (v0.7.4 conformance), NCIP server (admin UI, schema, seeds), Z39.50 server (ensureSchema() fix), BIBFRAME/RDF, ResourceSync, VIAF authority control
  • Archives plugin: multi-document uploads, UI polish

Root causes fixed in this batch

fr_FR not activating after upgrade

migrate_0.7.5.sql used INSERT IGNORE — if the row existed with is_active=0, the row was silently skipped. Switched to ON DUPLICATE KEY UPDATE is_active=1.

Site staying Italian after setting French as default

I18n::loadFromDatabase() queries WHERE is_active=1 — an is_active=0 language is invisible to the entire locale resolution chain even if is_default=1. Root fix: Language::setDefault() now forces is_active=1 on the target language.

"Key column 'source' doesn't exist in table" on upgrade

Some installations had author_authority_alternates created by an early dev migration with column source_code instead of source. CREATE TABLE IF NOT EXISTS skipped the table, then the subsequent ADD KEY idx_authority (source, authority_id) failed. Fixed by adding a guard that DROPs the table when the old source_code column is detected.

Test Plan

  • Fresh install: run installer wizard, verify all languages available
  • Upgrade from v0.7.4: apply ZIP via admin updater, verify fr_FR activated and site loads
  • Set fr_FR as default, verify site switches to French
  • Upgrade from installation with dev-schema author_authority_alternates: verify migration completes without error
  • PHPStan level 5: phpstan analyse --memory-limit=512M (0 errors)
  • E2E suite: full-test.spec.js (97 tests)

🤖 Generated with Claude Code

…bels (#123, #121)

- Add iiif_manifest_url VARCHAR(2000) column to archival_units (idempotent migration)
- GET /admin/archives/{id}/manifest.json and /archives/{id}/manifest.json return
  IIIF Presentation API 3.0 JSON with metadata crosswalk, seeAlso links to DC/OAI-PMH,
  and a Canvas for locally stored cover images; CORS header for external viewers
- iiif_manifest_url field in edit form links to external IIIF server manifests
- <link rel="alternate"> for manifest.json added to admin show and public detail pages
- form.php: wrap fields in ISAD(G) area section headers (Area 1 Identity Statement,
  Area 3 Content & Structure, Area 4 Conditions of Access) matching AtoM presentation
…tion endpoint

- iiifManifestAction: real canvas width/height via getimagesize() (fallback 1500×2000)
- iiifManifestAction: partOf link to parent Collection or root collection.json
- iiifManifestAction: structures[] with ancestor breadcrumb Range (IIIF TOC)
- iiifBuildAncestorChain(): walk parent_id chain to build Fondo › Serie › … path
- iiifCollectionAction(): GET /archives/collection.json (all top-level fondi)
  and GET /archives/{id}/collection.json (direct children as Manifest or Collection)
  — child units with children become sub-Collections, leaf units stay Manifests
- Added two public routes for Collection endpoints (CORS header included)
…authority links

- behavior: viewer UX hint (paged/individuals/unordered) derived from specific_material
- requiredStatement + provider: institution attribution as IIIF Agent (machine + human)
- rights: new rights_statement_url column (VARCHAR 500) exposed as manifest['rights']
- structures: replace flat breadcrumb with nested Range hierarchy (§1.1) — each
  ancestor wraps the next as an outer Range; innermost Range holds Canvas items
- authority links: creator/subject names + VIAF/Wikidata URIs from external_refs
  added to manifest metadata
- form: rights_statement_url field in ISAD(G) Area 4 (Conditions of Access)
- new private helpers: iiifBuildNestedStructures(), iiifFetchAuthoritiesWithRefs()
…oints

- ARK identifier (ark_identifier VARCHAR 255): surfaced in IIIF seeAlso via
  n2t.net resolver, EAD3 <recordid>, Dublin Core dc:identifier, form Area 1
- Version note (version_note VARCHAR 500): shown in IIIF metadata, form
- EAD3 per-unit: GET /archives/{id}/ead.xml (public + admin) — single-unit
  finding aid from existing writeEad3Document; adds <dao> IIIF manifest link
  and optional cover image to the bulk exporter as well
- METS per-unit: GET /archives/{id}/mets.xml (public + admin) — wraps DC
  inline + EAD3 by reference + IIIF manifest link; suitable for Europeana
  submission; covers fileSec with thumbnail if cover image present
- Dublin Core: adds ark as second dc:identifier, rights_statement_url as dc:rights
- IIIF seeAlso: adds EAD3, METS, ARK entries alongside existing DC and OAI-PMH
- headLinks: EAD3 and METS link rel="alternate" in admin and public show views
…erop standards

Covers all new fields (ark_identifier, version_note, rights_statement_url),
IIIF 3.0 advanced features (behavior, nested Ranges, requiredStatement, provider,
rights, authority links), per-unit EAD3 and METS exports, headLinks, and
ISAD(G) form labels validation.
…new fields in show.php

version_note and iiif_manifest_url are record-level metadata (not material-specific)
— moved outside the collapsible <details> accordion so they are always visible.
Added ark_identifier, rights_statement_url, version_note display to admin show view.
Fixed E2E test 8 to open the accordion before selecting specific_material.
All 25 interop E2E tests now pass.
… seed for new fields

- tests/seeds/librarything-aldebaran.tsv: 18 Aldebaran volumes (4 cycles)
  to test LibraryThing series import with collana/numero_serie parsing
- tests/seeds/books-general.csv: 15 classic books for standard CSV import
- tests/seeds/books-seed.sql: idempotent SQL seed (INSERT IGNORE) for fast
  setup; covers all 33 books, authors, publishers, series, and libri_collane links
- tests/seeds/archives-feature-20.sql (v2): adds ark_identifier,
  rights_statement_url, version_note to temp table and INSERT/ON DUPLICATE KEY
- .gitignore: allow *.csv and *.tsv in tests/seeds/
P1 — fresh-install schema integrity:
- Add 4 missing columns to ddlArchivalUnits() DDL (iiif_manifest_url,
  rights_statement_url, ark_identifier, version_note) so a fresh CREATE TABLE
  includes all interoperability-standards columns without needing ALTER
- Move migrateImageColumns() call to AFTER the CREATE TABLE foreach loop so
  the table exists before the additive ALTER migrations attempt to run

P2 — language-code parsing:
- iiifManifestAction(): use preg_split('/[,;\s]+/', ...) instead of
  explode(',', ...) so seed values like 'ita;eng' are correctly split
- Dublin Core output: replace single compound writeElementNs with a loop that
  emits one dc:language element per code (RFC 4646 / MARC 21 compliant)
…-up)

The seed archives-feature-20.sql inserts iiif_manifest_url,
rights_statement_url, ark_identifier, and version_note, but the
migration that archives-feature-documents.spec.js applies on a clean
DB stopped at document_filename. On a fresh install with the migration-
only path (no ensureSchema() call), the seed would fail with
'Unknown column'. Add four idempotent ALTER TABLE blocks following the
same pattern as all other additive columns in the migration.
cleanupTag (spec) — FK-safe deletion order: delete archival_unit_authority
rows first via JOIN, then archival_units children (parent_id IS NOT NULL)
then roots; avoids MySQL 1093 from self-referential NOT EXISTS subquery.

show.php — validate rights_statement_url scheme before rendering as <a href>:
only http/https URLs get a clickable link; anything else (incl. javascript:)
renders as plain escaped text, preventing href injection.

ArchivesPlugin — IIIF 3.0 §3.3 compliance: manifest items must have
cardinality ≥ 1; when no local cover image is present, add a minimal
placeholder Canvas so clients receive a valid (if unpainted) manifest.

ArchivesPlugin — EAD3 schema fix: move <daoset> block inside <did> (before
its closing tag) where EAD3 requires it; the block was previously emitted
after </did>, violating the EAD3 DTD.

form.php — add rel="noopener noreferrer" to external rightsstatements.org
link that uses target="_blank" (prevents tabnabbing/opener leakage).
ScrapeController::byIsbn() now calls session_write_close() before
hitting Discogs/OpenLibrary. PHP file-based sessions hold an exclusive
lock for the entire request duration, causing subsequent page.goto()
calls in Playwright (or any same-session request) to block until the
slow external fetch completes — typically 10–30 s, long enough to
cascade-fail all subsequent serial tests.

Also hardens seed-catalog.spec.js: replaces waitForTimeout(8000) with
waitForLoadState('networkidle'), adds trySave() helper that logs a
warning instead of throwing on save failure (seeder resilience), and
clears isbn13/isbn10 before fallback fill to avoid unique-constraint
violations.
ci-quality.yml (new):
- PHP syntax check with xargs -P4 on app/, config/, public/, installer/, server/
- PHPStan level 5 (mirrors pre-commit hook in CI for PRs from forks or CI-only runs)
- Composer security audit against Packagist CVE database
- Triggers on any *.php / composer.json / phpstan.neon change

codeql.yml (new):
- CodeQL security-and-quality queries for PHP and JavaScript
- Detects SQL injection, XSS, path traversal, CSRF, open redirect
- Runs on push/PR to main + weekly schedule

test-migrations.yml (updated):
- Added trigger on version.json changes
- Job verify-versions: checks every migrate_*.sql has version ≤ version.json;
  catches the silent-skip bug (updater guards with version_compare <=)
- Job test-full-chain: applies schema.sql + all migrations in sort -V order,
  then asserts 5 expected tables and 6 expected columns from 0.5.x/0.5.9.x
  migrations are present
- Existing job test-migrations (0.4.0 upgrade path) preserved unchanged
…ckout@v6

PHPStan installed via setup-php tools: doesn't auto-discover phpstan.neon
when run as a global binary in some 2.x versions, causing "At least one
path must be specified to analyse". Passing --configuration makes it
explicit and version-agnostic.

Also upgrades actions/checkout v4→v6 and actions/cache v4→v5 across all
three workflows to suppress the Node.js 20 deprecation warning (Node 24
becomes default June 2026).
Adds E2E_DB_HOST and E2E_DB_PORT env vars to mysqlArgs() in
archives-seed-50, archives-interop-standards and full-test specs.
Prioritises TCP (host+port) over socket when DB_HOST is set, enabling
remote DB access via SSH wrapper for production smoke-tests.
Both files were gitignored, so GitHub Actions could not find the
PHPStan config file during CI runs (exit 1: "does not exist").
Remove them from .gitignore so the runner picks them up on checkout.
v3 (toolset 2.25.3) does not recognize 'php' as a language, causing
the Analyze (php) job to fail. v4 restores PHP support and removes
the Node.js 20 deprecation warnings.
The /server/ directory is gitignored and absent in CI, causing
PHPStan to abort with "Path does not exist". No analyzed code
in app/ or plugins references classes from server/src, so the
entry is safe to remove.
PHP 8.2 constants like CURLOPT_REDIR_PROTOCOLS_STR were reported as
'not found' because phpVersion was set to 80100. The CI and production
server both run PHP 8.2; aligning the phpstan target version prevents
false positives on 8.2-only constants.
With phpVersion bumped to 80200, PHPStan resolves CURLOPT_REDIR_PROTOCOLS_STR
and collapses the old array-shape offset errors. Regenerated baseline now
captures only the two remaining legitimate suppressions.
PHPStan 2.1.54 (installed in CI via tools:phpstan) reports:
- CURLOPT_REDIR_PROTOCOLS_STR not found: constant is PHP 8.2 but absent
  from PHPStan 2.1.54 stubs; guarded by defined() at runtime
- isset.offset for traduttore/illustratore: stricter array-shape
  narrowing in 2.1.54 vs 2.1.40 (reportUnmatchedIgnoredErrors=false
  keeps these baseline entries harmless on older local versions)
thepixeldeveloper/sitemap is abandoned (no replacement available) —
the audit exits 2 by default. --abandoned=report logs it without
failing the pipeline.
CodeQL CLI 2.25.x does not recognize 'php' as a language, causing the
Analyze (php) job to fail. PHP security is already covered by PHPStan
level 5. Retain only javascript-typescript CodeQL analysis.
- tests/archives-interop-standards.spec.js: add escapeLike() helper and use
  ESCAPE '\\' in cleanupTag() LIKE queries to prevent SQL wildcard expansion
  on underscores in TAG (critical fix); remove -p${DB_PASS} from argv and
  pass password via MYSQL_PWD env var instead; fix test 12 n2t.net check
  to use startsWith() instead of includes(); refactor test 25 to use
  Playwright locators instead of page.content(); add Number.isInteger()
  guard in afterAll cleanup
- tests/archives-seed-50.spec.js: same MYSQL_PWD argv fix
- tests/full-test.spec.js: same MYSQL_PWD argv fix
- .github/workflows/test-migrations.yml: add workflow file to PR paths;
  replace || true with targeted idempotency handler that propagates
  unexpected errors; add rights_statement_url to column count (6→7)
- installer/database/migrations/migrate_0.5.9.7.sql: add UNIQUE KEY on
  archival_units.ark_identifier for upgrade paths
- storage/plugins/archives/ArchivesPlugin.php: add URL/length validation
  for iiif_manifest_url, rights_statement_url, ark_identifier, version_note
  in validateArchivalUnit(); add app-level ARK uniqueness check using
  excludeId to allow in-place updates; add UNIQUE KEY uq_ark_identifier to
  ensureSchema() CREATE TABLE; fix O(n) per-child queries in
  iiifCollectionAction() by computing has_children via EXISTS() subquery in
  the initial SELECT
- storage/plugins/archives/views/form.php: wrap placeholder in $e(); wrap
  parent_id help text in __()/$e() for i18n consistency
- app/Controllers/ScrapeController.php: add comment after session_write_close()
  warning plugin authors not to write to $_SESSION in Hook callbacks
- app/Support/MaintenanceService.php: add expired_pickups? to runIfNeeded()
  return type shape to fix PHPStan nullCoalesce.offset warning properly
- phpstan-baseline.neon: remove expired_pickups suppression (now fixed in
  the type annotation)
- tests/seeds/archives-feature-20.sql: version_note TEXT → VARCHAR(500) to
  match archival_units column type and prevent silent truncation
- migrate_0.5.9.7.sql: add deduplication pre-check before UNIQUE KEY
  (NULL-out duplicate ARKs keeping earliest row, guarded by idx_exists)
- phpstan-baseline.neon: remove 3 stale suppressions (traduttore,
  illustratore, CURLOPT_REDIR_PROTOCOLS_STR); keep booleanNot.alwaysTrue
  which is a real PHPStan finding in QueryCache while-loop
- ArchivesPlugin validateArchivalUnit(): remove AND deleted_at IS NULL
  from ARK uniqueness check to align with DB UNIQUE KEY semantics
- ArchivesPlugin iiifCollectionAction(): restrict root collection to
  level='fonds' only (parent_id IS NULL was too broad)
- tests: (body['seeAlso'] || []).find(...) guard against absent seeAlso;
  'Content & Structure' selector instead of ambiguous 'Content'
The migration renames the typo column classificazione_dowey→classificazione_dewey.
On fresh schema installs the correct column already exists, so CHANGE COLUMN
fails with 'Unknown column' — not in the CI tolerated-error list. Wrap with
INFORMATION_SCHEMA guard (same pattern as migrate_0.5.9.7.sql).
…0.5.9.7 upgrades

migrate_0.5.9.sql has version 0.5.9 which is older than the released 0.5.9.6,
so it never runs when upgrading from 0.5.9.x. This caused Test B of the reinstall
test to fail with 'archival_units doesn't exist'. The fix merges the full archives
DDL (CREATE TABLE IF NOT EXISTS + idempotent column additions + plugin row) into
migrate_0.5.9.7.sql so that any upgrade path from before 0.5.9.7 creates all
archives tables in a single idempotent pass.

Also move the book-type badge above the <h1> on the frontend book-detail page and
set an absolute font-size (0.7rem) so it renders small regardless of heading size.
…0.5.9.7 upgrades

migrate_0.5.9.sql has version 0.5.9 which is older than released 0.5.9.6,
so it never runs when upgrading from 0.5.9.x. Confirmed by reinstall-test.sh
Test B: upgrade from v0.5.9.6 failed with 'archival_units doesn't exist'.

Merged the full archives DDL into this file: CREATE TABLE IF NOT EXISTS for all
4 tables, idempotent column additions via INFORMATION_SCHEMA guards, plugin row
upsert, and the original dedup+UNIQUE KEY — so any pre-0.5.9.7 upgrade path
gets all archives tables in a single idempotent pass.
PHPStan baseline:
- Restore 3 suppressed entries removed erroneously: CURLOPT_REDIR_PROTOCOLS_STR
  (PHP 8.1 compatibility), traduttore/illustratore isset.offset in BookRepository

migrate_0.5.9.7.sql:
- Fix plugin version 1.0.0 → 1.1.0 to match storage/plugins/archives/plugin.json
- Use full extended ENUM for specific_material ADD COLUMN (avoids redundant MODIFY)
- Emit dedup count as migration_notice before nulling duplicate ARKs

ArchivesPlugin.php:
- dc.xml headLinks/seeAlso: application/rdf+xml → application/xml (OAI-DC is not
  RDF/XML; wrong MIME breaks parsers)
- validateArchivalUnit: normalize resolver URLs (https://n2t.net/ark:/...) to
  canonical ark:/... form and reject non-ARK strings with a clear error message
- migrateImageColumns: add idempotent guard for uq_ark_identifier UNIQUE KEY so
  in-place upgrades get the same DB-level uniqueness as fresh installs

tests/archives-interop-standards.spec.js:
- Test 20 EAD3 bulk export: remove manual Cookie forwarding (page.request already
  shares the browser context's storage)
- Restore `novalidate` on #bookForm (removed in 2c0264e), which caused
  browser HTML5 validation to intercept submit before the SweetAlert2
  handler could fire — breaking book create/edit confirmation dialog
- Fix smoke-install.spec.js search parameter: the /api/libri endpoint
  uses `search_text` not `search[value]` (DataTables format), so the
  wrong parameter silently returned the first 5 alphabetical books
  instead of the searched title, making createdBookId wrong and
  the edit-title verification fail
- fr_FR.json: +65 translated strings for LibraryThing plugin sections
  (Recensione e Valutazione, Visibilità nel Frontend, Condizione Fisica, etc.)
- en_US.json, de_DE.json: add missing "Valore Corrente" translation
- book_form.php: wrap LibraryThing field labels with __() so they
  respect the active locale (previously always rendered in Italian)
- full-test.spec.js: Phase 21 (4 serial tests) verifies fr/en/de/it
  language switching including LibraryThing French strings assertion
- Minor fixes: archives plugin.json trailing comma, CI workflow tweaks,
  phpstan baseline cleanup, test file updates
Upgrades from 0.7.4 → 0.7.5 now execute migrate_0.7.5.sql which
inserts the fr_FR language row (INSERT IGNORE, idempotent).
0.7.4 installs that already have the row will see a no-op.
…activate fr_FR

INSERT IGNORE left pre-existing fr_FR rows with is_active=0 untouched.
ON DUPLICATE KEY UPDATE now forces is_active=1 on upgrade, so the language
bootstrap (step 3: WHERE is_default=1 AND is_active=1) can find the French
locale after the admin sets it as default.
setDefault() now sets is_active=1 alongside is_default=1, preventing the
inconsistent state where a language is marked default but inactive.
I18n::bootstrap() step 3 requires is_active=1, so an inactive default
language was silently ignored and the app stayed in the previous locale.

Also adds missing bibframe.book route key to routes_de_DE.json.
… dev schema detected

Pre-release dev builds used source_code/source_id column names. The current
migration expects source/authority_id. CREATE TABLE IF NOT EXISTS skipped the
DDL on installations that had the dev table, causing ADD KEY idx_authority
(source, authority_id) to fail with 'Key column source does not exist'.

Add a guard that detects the old schema via source_code column presence and
drops the empty dev table before recreating with the correct schema.
const { execFileSync } = require('child_process');

const DB_HOST = process.env.E2E_DB_HOST || '';
const DB_PORT = process.env.E2E_DB_PORT || '';
return args;
}

function dbQuery(sql) {
const fs = require('fs');
const path = require('path');

const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081';
Comment thread tests/install-compat-standard.spec.js Fixed
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 11, 2026

Important

Review skipped

Too many files!

This PR contains 160 files, which is 10 over the limit of 150.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 405bc703-619d-44ca-8ba3-3a9ce4c46884

📥 Commits

Reviewing files that changed from the base of the PR and between d630ced and 332f723.

⛔ Files ignored due to path filters (7)
  • tests/fixtures/archive-test-audio.mp3 is excluded by !**/*.mp3
  • tests/fixtures/archive-test-cover.jpg is excluded by !**/*.jpg
  • tests/fixtures/archive-test.pdf is excluded by !**/*.pdf
  • tests/fixtures/test_import_languages.csv is excluded by !**/*.csv
  • tests/fixtures/test_import_languages.tsv is excluded by !**/*.tsv
  • tests/seeds/books-general.csv is excluded by !**/*.csv
  • tests/seeds/librarything-aldebaran.tsv is excluded by !**/*.tsv
📒 Files selected for processing (160)
  • .gitattributes
  • .github/workflows/ci-e2e.yml
  • .github/workflows/ci-quality.yml
  • .github/workflows/ci-upgrade-smoke.yml
  • .github/workflows/codeql.yml
  • .github/workflows/test-migrations.yml
  • .gitignore
  • README.md
  • app/Controllers/FrontendController.php
  • app/Controllers/ScrapeController.php
  • app/Controllers/SearchController.php
  • app/Models/Language.php
  • app/Support/BundledPlugins.php
  • app/Support/HtmlHelper.php
  • app/Support/MaintenanceService.php
  • app/Support/QueryCache.php
  • app/Support/RouteTranslator.php
  • app/Support/Updater.php
  • app/Views/admin/plugins.php
  • app/Views/frontend/book-detail.php
  • app/Views/frontend/catalog.php
  • app/Views/frontend/layout.php
  • app/Views/layout.php
  • app/Views/libri/partials/book_form.php
  • app/Views/user_layout.php
  • bin/pinakes
  • config/default_texts.php
  • installer/classes/Installer.php
  • installer/database/data_de_DE.sql
  • installer/database/data_en_US.sql
  • installer/database/data_fr_FR.sql
  • installer/database/data_it_IT.sql
  • installer/database/migrations/migrate_0.3.0.sql
  • installer/database/migrations/migrate_0.4.3.sql
  • installer/database/migrations/migrate_0.5.9.7.sql
  • installer/database/migrations/migrate_0.5.9.sql
  • installer/database/migrations/migrate_0.6.0.sql
  • installer/database/migrations/migrate_0.7.0.sql
  • installer/database/migrations/migrate_0.7.4.sql
  • installer/database/migrations/migrate_0.7.5.sql
  • installer/database/schema.sql
  • installer/index.php
  • installer/steps/step0.php
  • locale/de_DE.json
  • locale/en_US.json
  • locale/fr_FR.json
  • locale/it_IT.json
  • locale/routes_de_DE.json
  • locale/routes_en_US.json
  • locale/routes_fr_FR.json
  • locale/routes_it_IT.json
  • phpstan-baseline.neon
  • phpstan.neon
  • scripts/create-release.sh
  • storage/plugins/api-book-scraper/ApiBookScraperPlugin.php
  • storage/plugins/api-book-scraper/views/settings.php
  • storage/plugins/archives/ArchivesPlugin.php
  • storage/plugins/archives/assets/css/archives-public.css
  • storage/plugins/archives/plugin.json
  • storage/plugins/archives/views/authorities/show.php
  • storage/plugins/archives/views/form.php
  • storage/plugins/archives/views/index.php
  • storage/plugins/archives/views/public/index.php
  • storage/plugins/archives/views/public/show.php
  • storage/plugins/archives/views/show.php
  • storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php
  • storage/plugins/bibframe-linked-data/plugin.json
  • storage/plugins/bibframe-linked-data/wrapper.php
  • storage/plugins/dewey-editor/DeweyEditorPlugin.php
  • storage/plugins/dewey-editor/classes/DeweyValidator.php
  • storage/plugins/dewey-editor/views/editor.php
  • storage/plugins/ncip-server/NcipServerPlugin.php
  • storage/plugins/ncip-server/plugin.json
  • storage/plugins/ncip-server/views/partners.php
  • storage/plugins/ncip-server/views/transactions.php
  • storage/plugins/ncip-server/wrapper.php
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php
  • storage/plugins/oai-pmh-server/plugin.json
  • storage/plugins/oai-pmh-server/views/book-digital-assets.php
  • storage/plugins/oai-pmh-server/wrapper.php
  • storage/plugins/open-library/OpenLibraryPlugin.php
  • storage/plugins/openurl-resolver/OpenUrlResolverPlugin.php
  • storage/plugins/openurl-resolver/plugin.json
  • storage/plugins/openurl-resolver/wrapper.php
  • storage/plugins/resource-sync/ResourceSyncPlugin.php
  • storage/plugins/resource-sync/plugin.json
  • storage/plugins/resource-sync/wrapper.php
  • storage/plugins/viaf-authority/ViafAuthorityPlugin.php
  • storage/plugins/viaf-authority/plugin.json
  • storage/plugins/viaf-authority/wrapper.php
  • storage/plugins/z39-server/Z39ServerPlugin.php
  • storage/plugins/z39-server/classes/DublinCoreFormatter.php
  • storage/plugins/z39-server/classes/MARCXMLFormatter.php
  • storage/plugins/z39-server/classes/MODSFormatter.php
  • storage/plugins/z39-server/classes/RecordFormatter.php
  • storage/plugins/z39-server/classes/SRUServer.php
  • storage/plugins/z39-server/classes/SruClient.php
  • storage/plugins/z39-server/classes/UNIMARCXMLFormatter.php
  • storage/plugins/z39-server/endpoint.php
  • tests/archives-00-schema.spec.js
  • tests/archives-50-elements-crud.spec.js
  • tests/archives-authorities.spec.js
  • tests/archives-bug-fixes.spec.js
  • tests/archives-crud.spec.js
  • tests/archives-full.spec.js
  • tests/archives-interop-standards.spec.js
  • tests/archives-multi-documents.spec.js
  • tests/archives-plugin.unit.php
  • tests/archives-pr-extended.spec.js
  • tests/archives-search.spec.js
  • tests/archives-seed-50.spec.js
  • tests/archives-unit-files.unit.php
  • tests/archives-upload-assets.spec.js
  • tests/bibframe-linked-data.spec.js
  • tests/bibframe-persistent-uri.spec.js
  • tests/bnf-sru-features.spec.js
  • tests/book-form-comprehensive.spec.js
  • tests/branch-fix-consistency.spec.js
  • tests/bulk-enrich.spec.js
  • tests/code-quality.spec.js
  • tests/discogs-advanced.spec.js
  • tests/discogs-import.spec.js
  • tests/email-notifications.spec.js
  • tests/fair-signposting.spec.js
  • tests/fr-bnf-merge-consistency.spec.js
  • tests/full-test.spec.js
  • tests/helpers/branch-fix-harness.php
  • tests/install-compat-standard.spec.js
  • tests/install-english-issue112.spec.js
  • tests/install-french.spec.js
  • tests/interop-00-activate-plugins.spec.js
  • tests/interop-document-coverage.spec.js
  • tests/interop-specific.spec.js
  • tests/interop-standards.spec.js
  • tests/loan-reservation.spec.js
  • tests/mag-validation.spec.js
  • tests/multilang-install-i18n.spec.js
  • tests/multisource-scraping.spec.js
  • tests/ncip-admin-ui.spec.js
  • tests/ncip-server.spec.js
  • tests/oai-pmh-server.spec.js
  • tests/openurl-resolver.spec.js
  • tests/plugin-integrity.spec.js
  • tests/plugin-lifecycle.spec.js
  • tests/resource-sync.spec.js
  • tests/schema-integrity.spec.js
  • tests/seed-catalog.spec.js
  • tests/seeds/archives-feature-20.sql
  • tests/seeds/archives-unit-files.sql
  • tests/seeds/authors-with-viaf.sql
  • tests/seeds/books-seed.sql
  • tests/seeds/ncip-partners.sql
  • tests/seeds/oai-conformance-set.sql
  • tests/series-cycles.spec.js
  • tests/smoke-install.spec.js
  • tests/sru-unimarcxml.spec.js
  • tests/unimarc-export.spec.js
  • tests/viaf-authority.spec.js
  • tests/viaf-reconcile.spec.js
  • version.json

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/fr-bnf-integration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

…sue #130)

On Windows, RecursiveDirectoryIterator::getPathname() returns backslash paths,
breaking str_replace prefix-stripping and mkdir target construction in
copyDirectoryRecursive() and copyDirectory(). All paths now normalized to
forward slashes at function entry; realpath() returns also normalized.

Also remove unused SEED_FILES variable from install-compat-standard.spec.js
(CodeQL js/unused-local-variable #20) and harden test selectors.
Comment thread tests/archives-upload-assets.spec.js Fixed
Comment thread tests/archives-upload-assets.spec.js Fixed
Comment thread tests/archives-upload-assets.spec.js Fixed
Comment thread tests/archives-upload-assets.spec.js Fixed
Comment thread tests/archives-upload-assets.spec.js Fixed
Comment thread tests/archives-upload-assets.spec.js Fixed
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 11, 2026

✅ Actions performed

Full review triggered.

…ition

archives-upload-assets.spec.js ensureUploadFixtures():
- Remove existsSync+writeFileSync TOCTOU pattern (race condition fix)
- Replace hardcoded /tmp/ paths with path.join(os.tmpdir(), ...) for
  cross-platform safety and to satisfy CodeQL js/insecure-tmp rule
Comment thread tests/archives-upload-assets.spec.js Fixed
Comment thread tests/archives-upload-assets.spec.js Fixed
Comment thread tests/archives-upload-assets.spec.js Fixed
__() uses Italian as the native fallback — keys ARE Italian text, so
if a key is missing from it_IT.json the function returns the key itself.
it_IT.json is intentionally sparse (overrides only).

Only en_US ↔ de_DE requires exact key-set parity.
CodeQL js/insecure-temporary-file:
  - Add binary fixtures to tests/fixtures/ (jpg/pdf/mp3, csv/tsv) and
    allow the directory in .gitignore (mirrors tests/seeds/ pattern)
  - archives-upload-assets.spec.js reads from tests/fixtures/ instead of
    writing predictable filenames to os.tmpdir()

OAI-PMH:
  - Catch remaining \Throwable in record serialisation loop to skip
    malformed metadata with a warning log instead of crashing

E2E test stability:
  - bibframe-persistent-uri: check @id contains /id/work/{id}, not suffix /work
  - book-form-comprehensive: simplified Choices.js check (no XPath)
  - bulk-enrich: nullify ISBN before DELETE to avoid UK constraint on re-run
  - multisource-scraping: accept 'Z39.50/SRU' as valid source for Nevermind
  - smoke-install: make author input fill conditional (visible guard)
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

fabiodalez-dev commented May 11, 2026

Code review

Branch: feat/fr-bnf-integrationmain
PR: #132 (open)
Review ID: rev_01KRB6B3SXD2MQW9F49YFWA7Z3
Sub-agent tokens: 2,232,501 across 31 invocations

Found 29 findings across all lanes:

  • Deep lane (correctness/security): 3 resolved, 2 uncertain

Deep lane — correctness & security

✓ Auto-fixable (3)

# Score Impact File Issue Status
F002 78 correctness storage/plugins/archives/ArchivesPlugin.php:1714-1716 In removeFileAction(), $stmt->get_result() is called and the return value is used directly as a mysqli_result without an instanceof \mysqli_result guard. If get_result() returns false (non-mysqlnd driver, or any query error), calling ->fetch_assoc() on false causes a fatal PHP TypeError. Every other get_result() call in the codebase uses the guard pattern. ✓ fixed and verified (6a93828)
F003 78 correctness storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php:560-568 Scalar predicate value in toTurtle() uses addslashes() instead of the proper escapeTurtleString() helper used elsewhere in the same file, so scalar properties containing newline/tab/carriage-return characters produce malformed Turtle that parsers will reject. ✓ fixed and verified (6a93828)
F007 60 correctness storage/plugins/archives/ArchivesPlugin.php:6421-6425 archiveSearchPattern() builds a SQL LIKE pattern by concatenating '%' . $q . '%' without ESCAPE-clause-safe escaping of the literal % and _ wildcards in $q, so users searching for an underscore or percent get wrong matches. ✓ fixed and verified (6a93828)
Details and fix proposals

F002 — In removeFileAction(), $stmt->get_result() is called and the return value is used directly as a mysqli_result without an instanceof \mysqli_result guard. If get_result() returns false (non-mysqlnd driver, or any query error), calling ->fetch_assoc() on false causes a fatal PHP TypeError. Every other get_result() call in the codebase uses the guard pattern.

File: storage/plugins/archives/ArchivesPlugin.php:1714-1716
Score: 78 (strong)

Evidence:

  • storage/plugins/archives/ArchivesPlugin.php:1714 — $result = $stmt->get_result() assigns without instanceof guard
  • storage/plugins/archives/ArchivesPlugin.php:1715 — $file = $result->fetch_assoc() directly dereferences; on non-mysqlnd builds get_result() returns false, causing fatal Error
  • storage/plugins/archives/ArchivesPlugin.php:4680 — $row = $stmt->get_result()->fetch_assoc() chained form with same defect in iiifManifestAction
  • 47 instanceof \mysqli_result guards elsewhere in the same file establish this as the standard defensive pattern
  • PHP 8 raises Error (not TypeError) on method call on bool — unhandled, surfaces as HTTP 500

Approach: Add instanceof \mysqli_result guard at two unguarded sites: lines 1714-1715 in removeFileAction and line 4680 in iiifManifestAction, mirroring the 47 existing guarded sites

Files to modify:

  • storage/plugins/archives/ArchivesPlugin.php — Replace line 1714-1715 assignment with: $result = $stmt->get_result(); $file = $result instanceof \mysqli_result ? $result->fetch_assoc() : null; (why: Existing null branch at 1718 already handles failure case correctly with 303 redirect)
  • storage/plugins/archives/ArchivesPlugin.php — Refactor line 4680 chained call into two-statement form with guard: $res = $stmt->get_result(); $row = $res instanceof \mysqli_result ? $res->fetch_assoc() : null; (why: Same root cause; existing null check at 4683 returns 404 JSON)

Verification:

  • grep -n 'get_result()' storage/plugins/archives/ArchivesPlugin.php — confirm every call followed within 3 lines by instanceof guard
  • phpstan analyse --memory-limit=512M storage/plugins/archives/ArchivesPlugin.php

Edge cases to preserve:

  • Existing 303 redirect when fileId not found must still trigger via null branch
  • Filesystem unlink must only run when DB delete succeeded

Latest fix attempt (fixrun_01KRBB2ZT91936NP8ZB0X076FB): fixed and verified

F003 — Scalar predicate value in toTurtle() uses addslashes() instead of the proper escapeTurtleString() helper used elsewhere in the same file, so scalar properties containing newline/tab/carriage-return characters produce malformed Turtle that parsers will reject.

File: storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php:560-568
Score: 78 (strong)

Evidence:

  • storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php:562 — uses addslashes((string)$val) for scalar literal Turtle output
  • storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php:617,645 — both other literal-emission sites correctly use $this->escapeTurtleString()
  • escapeTurtleString() (lines 655-662) escapes backslash, quote, newline, carriage-return, tab; addslashes() misses newline/carriage-return/tab
  • Commit c84f03c fixed blank-node branch but missed top-level scalar branch at line 562
  • A scalar value containing literal LF (e.g. multi-line note) produces malformed Turtle STRING_LITERAL_QUOTE parsers will reject

Approach: Single-line fix: replace addslashes() with $this->escapeTurtleString() at line 562 so all three literal-emission paths share one escaping function

Files to modify:

  • storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php — At line 562 replace addslashes((string) $val) with $this->escapeTurtleString((string) $val) (why: addslashes() leaves LF/CR/TAB intact, violating Turtle STRING_LITERAL_QUOTE grammar; escapeTurtleString() already implements the full escape set used by sibling paths at lines 617 and 645)

Verification:

  • grep -n 'addslashes' storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php — must return 0 after fix
  • grep -n 'escapeTurtleString' storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php — all three sites (562, 617, 645) must use it

Edge cases to preserve:

  • Empty string scalar must still emit empty quoted string
  • ASCII-only scalars unchanged
  • Single quote — addslashes escapes unnecessarily; escapeTurtleString leaves raw (correct for Turtle)

Latest fix attempt (fixrun_01KRBB2ZT91936NP8ZB0X076FB): fixed and verified

F007 — archiveSearchPattern() builds a SQL LIKE pattern by concatenating '%' . $q . '%' without ESCAPE-clause-safe escaping of the literal % and _ wildcards in $q, so users searching for an underscore or percent get wrong matches.

File: storage/plugins/archives/ArchivesPlugin.php:6421-6425
Score: 60 (moderate)

Evidence:

  • storage/plugins/archives/ArchivesPlugin.php:6421-6425 — archiveSearchPattern() concatenates '%' . $stem . '%' with no % or _ escaping
  • storage/plugins/archives/ArchivesPlugin.php:973 — $q originates from getQueryParams()['q'], only trim()-ed
  • storage/plugins/archives/ArchivesPlugin.php:4127 — sibling searchAuthorityRecordsForTypeahead() correctly uses str_replace(['\','%','_'],...) — clear local precedent
  • Four callers all use bind_param 'ssss' LIKE ? with no ESCAPE clause
  • git log confirms archiveSearchPattern introduced on this PR branch, not pre-existing on main — origin=pre_existing in artifact is incorrect

Approach: In archiveSearchPattern(), escape backslash then % and _ in $q before the stem-trim and '%...%' wrap, mirroring line 4127. All four callers automatically fixed.

Files to modify:

  • storage/plugins/archives/ArchivesPlugin.php — In archiveSearchPattern() body: add str_replace(['\', '%', '_'], ['\\', '\%', '_'], $q) before the stem-trim. Escape first, then substr, to avoid splitting multi-char escape sequences. (why: Restores correctness for queries containing % or _; aligns with sibling and project-wide LIKE escaping convention)

Verification:

  • grep -n 'archiveSearchPattern' storage/plugins/archives/ArchivesPlugin.php — 1 producer + 4 consumers
  • Manual: search 'foo_bar' against seeded units — must NOT match 'fooXbar'

Edge cases to preserve:

  • Italian stemming (strip last char when strlen>=5) applied to escaped string
  • Empty $q: callers short-circuit before calling
  • Backslash escape order: replace backslash first

Latest fix attempt (fixrun_01KRBB2ZT91936NP8ZB0X076FB): fixed and verified

ℹ Uncertain (2)

# Score Impact File Issue
F031 48 security storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2497-2524 requireAdminForDownload() accepts HTTP Basic Auth credentials in-band over potentially unencrypted HTTP connections, transmitting admin passwords in cleartext if TLS is not enforced at the infrastructure level.
F032 58 correctness app/Controllers/FrontendController.php:830-890 FrontendController catalog search builds LIKE patterns by wrapping $wordBase in '%...' without escaping literal % and _ wildcards, unlike AutoriApiController and CollaneController which use str_replace escaping — users searching for underscore or percent get wrong matches on the main book catalog.

Phase 4 couldn't confirm decisively. Re-run /adamsreview:review if you suspect this deserves
further investigation with fresh context.

Fix runs

Run fixrun_01KRBB2ZT91936NP8ZB0X076FB — 2026-05-11T11:17:54Z

  • Outcomes: 9 fixed and verified
  • Commits: 6a93828
Finding Group Outcome phase_9_finding
F002 FG-1 ✓ fixed and verified
F003 FG-2 ✓ fixed and verified
F005 FG-3 ✓ fixed and verified
F007 FG-1 ✓ fixed and verified
F010 FG-4 ✓ fixed and verified
F020 FG-5 ✓ fixed and verified
F023 FG-6 ✓ fixed and verified
F027 FG-7 ✓ fixed and verified
F028 FG-8 ✓ fixed and verified

🤖 Generated with Adam's Claude Code Review Command

@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

Auto-recommendation acceptance

rev_01KRB6B3SXD2MQW9F49YFWA7Z3 · run_id=fixrun_01KRBB2ZT91936NP8ZB0X076FB · choice=apply_all · threshold=60 · reviewer=auto-rec/fabiodalezbackup@gmail.com · ts=2026-05-11T11:02:56Z

6 auto-rec finding(s) eligible at threshold ≥ 60. Of those, 6 auto-promoted via batch, 0 promoted with an edited or alternative hint, 0 skipped per-finding.

Promoted (batch)

  • F005 — Plugin's ensureSchema() defines oai_deleted_records.entity_id as INT NOT NULL and oai_resumption_tokens with a created_at column, but installer/database/schema.sql defines entity_id as BIGINT UNSIGNED and oai_resumption_tokens without a created_at column — schema drift between plugin ensureSchema() and schema.sql.
    • Hint: In OaiPmhServerPlugin.php ensureSchema(), change the oai_deleted_records.entity_id column definition from INT NOT NULL to BIGINT UNSIGNED NOT NULL, and remove the created_at column from the oai_resumption_tokens CREATE TABLE statement, so both definitions match schema.sql exactly.
  • F010 — $archiveResults is computed in the full-page render path but catalogAPI() AJAX path does not compute archive results, so dynamic search-as-you-type never shows archives while the full-page render does.
    • Hint: In FrontendController.php catalogAPI(), replicate the Archives plugin hook call used in catalog() to compute $archiveResults, then merge those results into the JSON response payload (e.g., under an 'archives' key) so search-as-you-type returns the same data as the full-page render.
  • F020 — The Aggiungi button gives no in-progress feedback: it is not disabled and shows no loading indicator while the fetch is in flight, leaving the user able to double-submit.
    • Hint: In the Aggiungi button fetch handler in book-digital-assets.php, immediately before the fetch() call set btn.disabled = true and add a CSS loading/spinner class to the button, then in a finally block after the fetch resolves or rejects restore btn.disabled = false and remove the loading class — matching the pattern used in other forms (e.g., loan forms) in the codebase.
  • F023 — The Indietro back button uses history.back(), which navigates to whatever the previous browser history entry was; if the user arrived via a bookmark or external link, the button is broken.
    • Hint: Replace the bare history.back() call in show.php with a fallback: if document.referrer is non-empty and starts with window.location.origin use history.back(), otherwise assign window.location.href to the authorities list URL (e.g., the value of the route_path('archives_authorities') helper rendered into a data attribute on the button).
  • F027 — The Z39 server quote_search_terms checkbox label is not associated with the checkbox via a for/id pair, so clicking the label text does not toggle the checkbox and screen readers cannot programmatically connect the two elements.
    • Hint: In app/Views/admin/plugins.php around lines 1115-1118, add id='z39_quote_search_terms' to the checkbox input element and for='z39_quote_search_terms' to its sibling label element, mirroring the for/id pattern used on all other plugin setting checkboxes in the same file.
  • F028 — The iiif_manifest_url field is saved through the form but never displayed in the admin detail view, so the admin cannot verify the stored IIIF manifest URL without re-opening the edit form.
    • Hint: In storage/plugins/archives/views/show.php within the detail view block (lines 399-430), add a conditionally-rendered row for iiif_manifest_url using the same pattern as digital_object_url: show the label and the value as an anchor with target='_blank', wrapped in a null/empty check so the row is omitted when the field is unset.

Auto-recommendation acceptance: append-only audit. Each /adamsreview:fix run posts a fresh entry. Promoted findings are now confirmed_mechanical with human_confirmation set; Phase 8 dispatches them next.

Fix groups (committed):
- [FG-1] F002, F007 — storage/plugins/archives/ArchivesPlugin.php: verified
- [FG-2] F003 — storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php: verified
- [FG-3] F005 — storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php: verified
- [FG-4] F010 — app/Controllers/FrontendController.php: verified
- [FG-5] F020 — storage/plugins/oai-pmh-server/views/book-digital-assets.php: verified
- [FG-6] F023 — storage/plugins/archives/views/authorities/show.php: verified
- [FG-7] F027 — app/Views/admin/plugins.php: verified
- [FG-8] F028 — storage/plugins/archives/views/show.php: verified

Post-fix review: 8/8 groups verified complete; 0 group(s) partial; 0 group(s) reverted.
const TEST_AUDIO = path.join(os.tmpdir(), 'archive-test-audio.wav');

function ensureAudioFixture() {
fs.writeFileSync(TEST_AUDIO, Buffer.from([
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

fabiodalez-dev commented May 11, 2026

Code review

Branch: feat/fr-bnf-integrationmain
PR: #132 (open)
Review ID: rev_01KRBD395Z1ABNPQ3WQ1R8X945
Sub-agent tokens: 1,304,012 across 22 invocations

Found 47 findings across all lanes:

  • Deep lane (correctness/security): 8 resolved, 2 uncertain

Deep lane — correctness & security

✓ Auto-fixable (8)

# Score Impact File Issue Status
F003 82 correctness storage/plugins/z39-server/classes/SRUServer.php:1048-1060 errorResponse() builds child elements with createElement() while all other response methods use createElementNS(NS_SRU,...) — error responses will have children in no namespace ✓ fixed and verified (461083b)
F018 88 correctness storage/plugins/z39-server/classes/SruClient.php:708-711 UNIMARC 461 $t (series title) written to numero_serie (volume number / UNSIGNED INT); series title should go to collana/series field ✓ fixed and verified (461083b)
F019 78 correctness storage/plugins/openurl-resolver/OpenUrlResolverPlugin.php:624-634 localBookUrl() hardcodes '/libro/{id}' instead of RouteTranslator::route('book'); en_US installs get 404 ✓ fixed and verified (461083b)
F020 62 correctness storage/plugins/resource-sync/ResourceSyncPlugin.php:308-321 fetchChangedBooks() (no-since) SELECT from libri without deleted_at IS NULL — leaks soft-deleted book IDs to harvesters and violates CLAUDE.md rule (human-confirmed) ✓ fixed and verified (461083b)
F024 70 correctness storage/plugins/ncip-server/NcipServerPlugin.php:1211-1213 UPDATE libri in createLoanAtomic() missing AND deleted_at IS NULL — violates CLAUDE.md absolute soft-delete rule ✓ fixed and verified (461083b)
F025 78 correctness storage/plugins/ncip-server/NcipServerPlugin.php:1322-1324 UPDATE libri in closeLoan() missing AND deleted_at IS NULL — violates CLAUDE.md absolute soft-delete rule ✓ fixed and verified (461083b)
F027 72 correctness storage/plugins/viaf-authority/ViafAuthorityPlugin.php:51-55 ensureSchema() returns void instead of array{created,failed}; onActivate() cannot check $result['failed'] per Plugin Schema Rule ✓ fixed and verified (461083b)
F045 62 security storage/plugins/ncip-server/NcipServerPlugin.php:380-383 requireAdmin() checks only tipo_utente === 'admin'; plugin docs state 'admin/staff' can access write endpoints — privilege mismatch ✓ fixed and verified (461083b)

Cross-cutting group G1: F024 + F025 — Both findings are the same soft-delete invariant violation in the same file (NcipServerPlugin.php), targeting two UPDATE libri statements in adjacent transactional flows (createLoanAtomic at line 1212 and closeLoan at line 1323). They must be patched together in a single edit pass to ensure the loan create/close lifecycle remains consistent and both WHERE clauses are updated atomically.

Details and fix proposals

F003 — errorResponse() builds child elements with createElement() while all other response methods use createElementNS(NS_SRU,...) — error responses will have children in no namespace

File: storage/plugins/z39-server/classes/SRUServer.php:1048-1060
Score: 82 (strong)

Evidence:

  • SRUServer.php:1056 — root uses createElementNS(self::NS_SRU, $rootElement), correctly namespaced
  • SRUServer.php:1059 — $versionEl created with createElement('version') (no namespace)
  • SRUServer.php:1062 — $diagnostics created with createElement('diagnostics') (no namespace)
  • SRUServer.php:1065 — $diagnostic created with createElement('diagnostic') (no namespace)
  • SRUServer.php:1068 — $uri created with createElement('uri') (no namespace)
  • SRUServer.php:1071 — $details created with createElement('details') (no namespace)
  • SRUServer.php:1074 — $messageEl created with createElement('message') (no namespace)
  • SRUServer.php:85 — NS_SRU = 'http://www.loc.gov/zing/srw/'
  • SRUServer.php:797-863 — searchRetrieveResponse() consistently uses createElementNS($ns,...) for every child (FIX 1 comment)
  • SRUServer.php:1003-1033 — scanResponse() also uses createElementNS for every child (FIX 1b comment)
  • 14 callsites of errorResponse() across handleRequest, searchRetrieve, and scan

Approach: Add $ns = self::NS_SRU; and replace every createElement() child with createElementNS($ns, ...) in errorResponse(), mirroring the FIX 1 / FIX 1b pattern in sibling methods.

Files to modify:

  • storage/plugins/z39-server/classes/SRUServer.php — In errorResponse(): add $ns = self::NS_SRU after appending root; change createElement('version') -> createElementNS($ns,'version'); same for diagnostics, diagnostic, uri, details, message. Add // FIX 1c comment. (why: Without namespaced children, serialized XML emits elements with empty-namespace reset, breaking XPath under srw: prefix and SRU XSD validation. Sibling methods already document this fix; errorResponse() was missed.)

Verification:

  • Trigger an error response via curl and inspect with xmllint --format
  • Confirm every element prints with srw: prefix, no xmlns='' reset
  • Run xmllint --noout --schema sru-1.2.xsd response.xml

Edge cases to preserve:

  • $operation match for root element name selection
  • NS_DIAG concatenation in $uri text content (the URI value, not the element namespace)
  • escapeXml() calls on $message and $version

Latest fix attempt (fixrun_01KRBHK6KSS54SJQP08FVWQ8C4): fixed and verified

F018 — UNIMARC 461 $t (series title) written to numero_serie (volume number / UNSIGNED INT); series title should go to collana/series field

File: storage/plugins/z39-server/classes/SruClient.php:708-711
Score: 88 (strong)

Evidence:

  • SruClient.php:558 — documentation comment correctly states '461 $t = series title' per UNIMARC spec
  • SruClient.php:707-711 — code comment says '461 $t' but assigns title value to $book['numero_serie'] (the volume-number field)
  • schema.sql:420-421 — collana varchar(100) (series title) and numero_serie varchar(50) (volume number); mismatch confirmed
  • book_form.php:525-534 — UI: collana='Serie principale', numero_serie='Numero Serie' with placeholder 'es. 15' (numeric)
  • BookRepository.php:109-113 — UNIMARCXMLFormatter exports collana->225 $a and numero_serie->225 $v confirming convention
  • BookRepository.php:1080-1095 — saveFromScrape() regex splits series into collana (name) + numero_serie (digit-only number)
  • SruClient.php — no parsing of 461 $v (volume) anywhere; volume number from BnF silently dropped

Approach: Map UNIMARC 461 $t to collana (series title) and 461 $v to numero_serie (volume number); fallback to 225 $a/$v when 461 absent; truncate to varchar(50) defensively.

Files to modify:

  • storage/plugins/z39-server/classes/SruClient.php — Replace lines 707-711 with: $seriesTitle = $getSub('461','t') ?? $getSub('225','a'); if ($seriesTitle !== null) $book['collana'] = $clean($seriesTitle); $seriesVol = $getSub('461','v') ?? $getSub('225','v'); if ($seriesVol !== null) $book['numero_serie'] = mb_substr($clean($seriesVol),0,50); (why: Aligns parser with UNIMARC spec (in-file comment at line 558) and UNIMARCXMLFormatter convention (BookRepository:109-113); restores round-trip integrity for BnF imports)

Verification:

  • Feed parseMarcxchangeXml fixture with 461 $t='La Pléiade' 461 $v='42' — assert collana==='La Pléiade' and numero_serie==='42'
  • Repeat with 225 $a/$v fallback
  • Run E2E BnF SRU import test

Edge cases to preserve:

  • 461 absent + 225 absent → no writes
  • Non-numeric volume values fit in varchar(50)
  • Empty string after clean() → skip assignment

Latest fix attempt (fixrun_01KRBHK6KSS54SJQP08FVWQ8C4): fixed and verified

F019 — localBookUrl() hardcodes '/libro/{id}' instead of RouteTranslator::route('book'); en_US installs get 404

File: storage/plugins/openurl-resolver/OpenUrlResolverPlugin.php:624-634
Score: 78 (strong)

Evidence:

  • OpenUrlResolverPlugin.php:375 — return $origin . $basePath . '/libro/' . $bookId; hardcoded Italian path
  • routes_en_US.json — book route is /book, not /libro
  • routes_de_DE.json — book route is /buch
  • app/Routes/web.php:2329 — book routes registered per-locale in supportedLocales loop; /libro not registered on en_US-only installs
  • app/helpers.php:211-218 — book_url($book) returns canonical SEO URL /{authorSlug}/{bookSlug}/{id}; used by NotificationService, SearchController, FrontendController
  • CLAUDE.md rule Add z39-server plugin tables to installer schema #4 — no hardcoded locale-specific paths

Approach: Replace localBookUrl(request, int $bookId) with localBookUrl(request, array $book) and use book_url($book) instead of manual /libro/ concatenation; prepend only $origin (scheme://host[:port]) since book_url() already includes base path.

Files to modify:

  • storage/plugins/openurl-resolver/OpenUrlResolverPlugin.php — Change signature to localBookUrl(ServerRequestInterface $request, array $book). Build $origin from $request->getUri(). Return $origin . book_url($book). Remove $basePath . '/libro/' . $bookId concatenation. Update call site at resolveAction() to pass $book array. (why: book_url() is the codebase-wide canonical helper that respects locale, avoids /libro vs /book vs /buch split, and eliminates 404 on non-IT installs)

Verification:

  • On en_US-only install: GET /openurl?rft.isbn= — confirm 302 Location resolves to 200 (not 404)
  • On it_IT install: confirm book_url-based URL still resolves
  • phpstan analyse storage/plugins/openurl-resolver/

Edge cases to preserve:

  • Port handling: strip default ports, keep non-standard
  • Subfolder installs: book_url() already includes BASE_PATH — do NOT also prepend $basePath
  • book_url() falls back to generic slugs when title/author missing

Latest fix attempt (fixrun_01KRBHK6KSS54SJQP08FVWQ8C4): fixed and verified

F020 — fetchChangedBooks() (no-since) SELECT from libri without deleted_at IS NULL — leaks soft-deleted book IDs to harvesters and violates CLAUDE.md rule

File: storage/plugins/resource-sync/ResourceSyncPlugin.php:308-321
Score: 62 (moderate)
Human-confirmed: @auto-rec/fabiodalezbackup@gmail.com at 2026-05-11T12:55:03Z — auto-accepted via :fix Phase 7.5 preflight
Promoted from disposition=manual / actionability=manual / score_phase4=62
Fix direction: In fetchChangedBooks() no-since branch, replace the bare SELECT with WHERE (deleted_at IS NULL OR deleted_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)) so only live books and recent tombstones (≤30 days) are returned to harvesters. Add an inline comment explaining the 30-day window is an intentional tombstone exception to the CLAUDE.md soft-delete rule, required for ResourceSync protocol compliance. Leave the since-branch, fetchBooks(), and buildChangeList() unchanged.

Evidence:

  • ResourceSyncPlugin.php:491-495 — no-since branch: SELECT id, updated_at, created_at, deleted_at FROM libri ORDER BY COALESCE(deleted_at,updated_at,created_at) DESC — no deleted_at IS NULL, no time bound
  • ResourceSyncPlugin.php:474-489 — since-branch has comment 'Include deleted records so harvesters can remove them' — intentional tombstone advertising
  • ResourceSyncPlugin.php:412-420 — buildChangeList maps deleted_at !== null to rs:md change='deleted'
  • ResourceSyncPlugin.php:443-462 — fetchBooks() ResourceList DOES correctly filter deleted_at IS NULL
  • CLAUDE.md §2 — every libri query must include deleted_at IS NULL

Approach: Two options: (a) Strict CLAUDE.md compliance — add WHERE deleted_at IS NULL to no-since branch, accept no tombstones without ?from; (b) Bounded tombstone mode — add WHERE updated_at >= DATE_SUB(NOW(),INTERVAL 30 DAY) OR deleted_at >= DATE_SUB(NOW(),INTERVAL 30 DAY) to bound tombstone window. Recommend (b) for protocol alignment. Since-branch unchanged.

Files to modify:

  • storage/plugins/resource-sync/ResourceSyncPlugin.php — In fetchChangedBooks() no-since branch: add time-bounded WHERE clause (30-day window) or strict deleted_at IS NULL per maintainer preference. Add explanatory comment citing ResourceSync ChangeList tombstone semantics. (why: Bounds the leak surface (no full historical deletion dump) and documents the intentional tombstone exception to CLAUDE.md rule)

Verification:

  • Soft-delete a book and curl /resync/changelist.xml (no ?from): verify behavior per chosen policy
  • curl /resync/changelist.xml?from=2020-01-01 — tombstones still appear (since-branch unchanged)
  • grep 'FROM libri' in plugin — each match has deleted_at IS NULL or intentional tombstone clause

Edge cases to preserve:

  • since-branch tombstone behavior must not be touched
  • fetchBooks() ResourceList must continue to exclude deleted
  • buildChangeList() deleted_at handling

Latest fix attempt (fixrun_01KRBHK6KSS54SJQP08FVWQ8C4): fixed and verified

F024 — UPDATE libri in createLoanAtomic() missing AND deleted_at IS NULL — violates CLAUDE.md absolute soft-delete rule

File: storage/plugins/ncip-server/NcipServerPlugin.php:1211-1213
Score: 70 (moderate)

Evidence:

  • NcipServerPlugin.php:1211-1213 — UPDATE libri SET copie_disponibili = GREATEST(0, copie_disponibili - 1) WHERE id = ? — no AND deleted_at IS NULL
  • NcipServerPlugin.php:1183 — preceding SELECT FOR UPDATE DOES include deleted_at IS NULL (partial mitigation in same transaction)
  • NcipServerPlugin.php:1112 — fetchBook() correctly includes deleted_at IS NULL
  • CLAUDE.md §2 — absolute rule, no carve-outs

Approach: Add AND deleted_at IS NULL to both UPDATE libri WHERE clauses (F024 at line 1212 and F025 at line 1323) in a single patch.

Files to modify:

  • storage/plugins/ncip-server/NcipServerPlugin.php — Line 1212: append AND deleted_at IS NULL to WHERE id = ?. Update bind_param to add extra NULL bind or restructure as needed. (why: CLAUDE.md absolute rule §2; defense-in-depth even with FOR UPDATE preceding guard)

Verification:

  • grep -n 'UPDATE libri' storage/plugins/ncip-server/NcipServerPlugin.php — both must include AND deleted_at IS NULL
  • E2E: NCIP CheckOut on soft-deleted book — must fail cleanly

Edge cases to preserve:

  • affected_rows === 1 check still works (returns 0 for soft-deleted, triggers rollback)
  • GREATEST/LEAST clamps unchanged

Latest fix attempt (fixrun_01KRBHK6KSS54SJQP08FVWQ8C4): fixed and verified

F025 — UPDATE libri in closeLoan() missing AND deleted_at IS NULL — violates CLAUDE.md absolute soft-delete rule

File: storage/plugins/ncip-server/NcipServerPlugin.php:1322-1324
Score: 78 (strong)

Evidence:

  • NcipServerPlugin.php:1322-1324 — UPDATE libri SET copie_disponibili = LEAST(copie_totali, copie_disponibili + 1) WHERE id = ? — no AND deleted_at IS NULL
  • NcipServerPlugin.php:1306-1338 — closeLoan() has NO preceding SELECT on libri that filters deleted_at — unlike createLoanAtomic()
  • Bug is reachable in practice: book soft-deleted while NCIP loan is open
  • CLAUDE.md §2 absolute rule

Approach: Same combined patch as F024 — add AND deleted_at IS NULL to WHERE clause at line 1323.

Files to modify:

  • storage/plugins/ncip-server/NcipServerPlugin.php — Line 1323: append AND deleted_at IS NULL to WHERE id = ? (why: No preceding FOR UPDATE guard in closeLoan() — deletion between checkout and checkin is a real reachable case; affected_rows === 1 check will then roll back correctly)

Verification:

  • Create NCIP loan; soft-delete book; send CheckIn — assert rollback, copie_disponibili unchanged
  • grep -n 'UPDATE libri' must show AND deleted_at IS NULL on both lines

Edge cases to preserve:

  • LEAST(copie_totali, copie_disponibili + 1) cap unchanged
  • Atomic rollback of prestiti row when libri update fails

Latest fix attempt (fixrun_01KRBHK6KSS54SJQP08FVWQ8C4): fixed and verified

F027 — ensureSchema() returns void instead of array{created,failed}; onActivate() cannot check $result['failed'] per Plugin Schema Rule

File: storage/plugins/viaf-authority/ViafAuthorityPlugin.php:51-55
Score: 72 (moderate)

Evidence:

  • ViafAuthorityPlugin.php:51 — public function ensureSchema(): void — returns void, not array{created,failed}
  • ViafAuthorityPlugin.php:57-68 — onActivate() calls $this->ensureSchema() discarding return value, no $result['failed'] check
  • ViafAuthorityPlugin.php:70-73 — onInstall() also discards contract
  • OaiPmhServerPlugin.php:108-114 — reference: ensureSchema() returns array{created:list,failed:list}
  • OaiPmhServerPlugin.php:70-87 — reference onActivate() checks $result['failed'] and throws
  • CLAUDE.md Plugin Schema Rule — ensureSchema() must return array{created,failed}; onActivate must throw on $result['failed']

Approach: Change ensureSchema() to return array{created:list,failed:list}; wrap ensureSchemaColumns() and ensureAlternatesTable() calls in try/catch Throwable blocks, accumulating created/failed lists. Update onActivate() and onInstall() to check $result['failed'] and throw RuntimeException. Mirror OaiPmhServerPlugin pattern exactly.

Files to modify:

  • storage/plugins/viaf-authority/ViafAuthorityPlugin.php — Change ensureSchema(): void to ensureSchema(): array with @return array{created:list,failed:list}. Wrap sub-calls in try/catch, build created/failed lists. Update both onActivate() and onInstall() to: $result = $this->ensureSchema(); if (!empty($result['failed'])) throw new \RuntimeException('[ViafAuthority] Schema activation failed: '.implode(', ',$result['failed'])); (why: Brings ViafAuthorityPlugin into conformity with CLAUDE.md Plugin Schema Rule; aligns with reference plugins; surfaces aggregated error messages)

Verification:

  • phpstan analyse --memory-limit=512M storage/plugins/viaf-authority/
  • Activate on fresh DB — verify columns and alternates table created; onActivate completes
  • Re-activate — verify idempotency (no errors)
  • Activate with simulated DDL failure — verify RuntimeException with 'Schema activation failed' message

Edge cases to preserve:

  • Idempotency: IF NOT EXISTS guards on column adds must stay
  • ensureSchema() must be called OUTSIDE begin_transaction() (DDL implicit-commits)
  • Backfill of legacy source_code/source_id columns

Latest fix attempt (fixrun_01KRBHK6KSS54SJQP08FVWQ8C4): fixed and verified

F045 — requireAdmin() checks only tipo_utente === 'admin'; plugin docs state 'admin/staff' can access write endpoints — privilege mismatch

File: storage/plugins/ncip-server/NcipServerPlugin.php:380-383
Score: 62 (moderate)

Evidence:

  • NcipServerPlugin.php:28 — docblock: 'Only users with tipo_utente IN (admin,staff) may perform write operations'
  • NcipServerPlugin.php:380-384 — requireAdmin() returns true only when tipo_utente === 'admin'
  • NcipServerPlugin.php:1415-1420 — isStaff() already correctly checks in_array(role,['admin','staff'],true) for NCIP protocol writes
  • AdminAuthMiddleware.php:47 — app-wide gate uses tipo_utente !== 'admin' && tipo_utente !== 'staff'
  • OaiPmhServerPlugin.php:2858-2862 — sibling requireAdminOrStaff() uses in_array(['admin','staff']) for analogous endpoints

Approach: Rename requireAdmin() to requireAdminOrStaff() and broaden role check to in_array(tipo_utente, ['admin','staff'], true). Update 4 call sites. Optionally reuse existing isStaff() helper.

Files to modify:

  • storage/plugins/ncip-server/NcipServerPlugin.php — Replace requireAdmin() body with: return isset($_SESSION['user']) && in_array($_SESSION['user']['tipo_utente'] ?? '', ['admin','staff'], true); rename to requireAdminOrStaff(). Update call sites at lines 258, 278, 314, 338. (why: Aligns admin UI authorization with plugin docblock, isStaff() helper, AdminAuthMiddleware, and sibling OaiPmhServerPlugin.requireAdminOrStaff())

Verification:

  • Log in as tipo_utente='staff': GET /admin/plugins/ncip-server/partners → 200, POST partners → 302 success
  • Log in as tipo_utente='utente': all 4 endpoints still denied
  • phpstan level 5 clean

Edge cases to preserve:

  • isset() guard for anonymous
  • ?? '' default for missing tipo_utente
  • CSRF validation in create/delete handlers unchanged

Latest fix attempt (fixrun_01KRBHK6KSS54SJQP08FVWQ8C4): fixed and verified

ℹ Uncertain (2)

# Score Impact File Issue
F001 55 correctness storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2785-2793 digitalAssetAddAction binds $filesize (int) with type-char 's' in bind_param('issssiii'), not 'i'; $filesize is a BIGINT UNSIGNED column
F002 58 correctness app/Views/frontend/book-detail.php:180-184 sameAs is unconditionally assigned even when $sameAsLinks is empty [], injecting sameAs:[] into Schema.org output rather than omitting the key

Phase 4 couldn't confirm decisively. Re-run /adamsreview:review if you suspect this deserves
further investigation with fresh context.

Fix runs

Run fixrun_01KRBHK6KSS54SJQP08FVWQ8C4 — 2026-05-11T13:07:54Z

  • Outcomes: 11 fixed and verified
  • Commits: 461083b
Finding Group Outcome phase_9_finding
F003 FG-1 ✓ fixed and verified
F018 FG-2 ✓ fixed and verified
F019 FG-3 ✓ fixed and verified
F020 FG-4 ✓ fixed and verified
F023 FG-5 ✓ fixed and verified
F024 FG-6 ✓ fixed and verified
F025 FG-6 ✓ fixed and verified
F027 FG-7 ✓ fixed and verified
F028 FG-8 ✓ fixed and verified
F029 FG-9 ✓ fixed and verified
F045 FG-6 ✓ fixed and verified

🤖 Generated with Adam's Claude Code Review Command

@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

Auto-recommendation acceptance

rev_01KRBD395Z1ABNPQ3WQ1R8X945 · run_id=fixrun_01KRBHK6KSS54SJQP08FVWQ8C4 · choice=apply_all · threshold=60 · reviewer=auto-rec/fabiodalezbackup@gmail.com · ts=2026-05-11T12:55:39Z

4 auto-rec finding(s) eligible at threshold ≥ 60. Of those, 4 auto-promoted via batch, 0 promoted with edited/alternative hint, 0 skipped per-finding.

Promoted (batch)

  • F020 — fetchChangedBooks() (no-since) SELECT from libri without deleted_at IS NULL — leaks soft-deleted book IDs to harvesters and violates CLAUDE.md rule
    • Hint: In fetchChangedBooks()no-since branch, replace the bareSELECTwithWHERE (deleted_at IS NULL OR deleted_at >= DATE_SUB(NOW(), INTERVAL 30 DAY))so only live books and recent tombstones (≤30 days) are returned to harvesters. Add an inline comment explaining the 30-day window is an intentional tombstone exception to the CLAUDE.md soft-delete rule, required for ResourceSync protocol compliance. Leave the since-branch,fetchBooks(), and buildChangeList() unchanged.
  • F023 — session_write_close() before Hook dispatch; Hook callbacks reading $_SESSION get closed-session snapshot, cannot see concurrent session updates
    • Hint: This is an architectural trade-off, not a mechanical fix: session_write_close()is intentionally placed before Hook dispatch to release the session lock and avoid blocking concurrent requests. Update the inline comment at line 82 to explicitly state that Hook callbacks invoked from this point forward MUST NOT read or write$_SESSION, and add a note to the Hook registration documentation (or a @session-unsafe tag on the hook name) so future callback authors are warned at the point of registration rather than at runtime.
  • F028 — ensureSchema() docblock says errors do not abort activation (partial success acceptable) but new migrateImageColumns()/migrateArchivalUnitFilesFK() throw RuntimeException, contradicting docblock
    • Hint: Update the ensureSchema()docblock (lines 237-244) to accurately state that migration failures (frommigrateImageColumns()andmigrateArchivalUnitFilesFK()) throw RuntimeException and therefore DO abort activation — the partial-success semantic described in the current docblock no longer applies. This is a one-line docblock correction that brings documentation into sync with actual behavior.
  • F029 — Class docblock lists four OAI-PMH metadata formats (oai_dc, marcxml, mods, mag) but code now also advertises/serves 'unimarc'
    • Hint: Add 'unimarc' to the class docblock's list of advertised metadata formats (lines 22-36) so the documentation matches the four original formats plus the newly served one. No code change is needed — this is a docblock-only update.

Auto-recommendation acceptance: append-only audit. Promoted findings are now confirmed_mechanical with human_confirmation set; Phase 8 dispatches them next.

Fix groups (committed):
- [FG-1] F003 — storage/plugins/z39-server/classes/SRUServer.php: verified
- [FG-2] F018 — storage/plugins/z39-server/classes/SruClient.php: verified
- [FG-3] F019 — storage/plugins/openurl-resolver/OpenUrlResolverPlugin.php: verified
- [FG-4] F020 — storage/plugins/resource-sync/ResourceSyncPlugin.php: verified
- [FG-5] F023 — app/Controllers/ScrapeController.php: verified
- [FG-6] F024, F025, F045 — storage/plugins/ncip-server/NcipServerPlugin.php: verified
- [FG-7] F027 — storage/plugins/viaf-authority/ViafAuthorityPlugin.php: verified
- [FG-8] F028 — storage/plugins/archives/ArchivesPlugin.php: verified
- [FG-9] F029 — storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php: verified

Post-fix review: 9/9 groups verified complete; 0 group(s) partial; 0 group(s) reverted.
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

Walkthrough decisions — 2026-05-11T13:15:03Z

Reviewer: fabiodalezbackup@gmail.com
Score floor: 60
Tier chosen: none (walk scope empty at threshold 60)
Findings walked: 0
Pre-existing findings: 1


Pre-existing findings

ID File Claim Action
F048 app/Views/frontend/book-detail.php:1623 ucfirst($author['ruolo']) echoed without htmlspecialchars — stored XSS if ruolo holds attacker-controlled data Fixed directly — added htmlspecialchars(ucfirst(...), ENT_QUOTES, 'UTF-8')

Generated by /adamsreview:walkthrough (threshold=60)

@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

fabiodalez-dev commented May 11, 2026

Code review

Branch: feat/fr-bnf-integrationmain
PR: #132 (open)
Review ID: rev_01KRCFVJ2J4S9RJ989JXT3FJY5
Sub-agent tokens: 872,726 across 17 invocations

Found 34 findings across all lanes:

  • Deep lane (correctness/security): 3 auto-fixable, 3 resolved
  • Light lane (ux/policy/architecture): 2 auto-fixable

Deep lane — correctness & security

✓ Auto-fixable (6)

# Score Impact File Issue Status
F007 82 correctness storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:597-614 earliestDatestamp in oaiIdentify uses DateTime::createFromFormat with no timezone — parses MySQL datetime in PHP's default TZ, then format('Y-m-d\TH:i:s\Z') appends Z literally; emitted datestamp is local time mislabelled as UTC. recordDatestamp() in the same file correctly uses strtotime+gmdate; this is the outlier. ✓ fixed and verified (332f723)
F008 55 correctness app/Controllers/FrontendController.php:742-747 bookDetail() issues SELECT 1 FROM plugins on every render — N+1 DB round-trip on the hottest page. No caching; runs even for anonymous catalog crawls. (human-confirmed)
F012 82 correctness storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2500-2520 writeBookMag calls fetchDigitalAsset per record — the batch path in fetchRecordsPage does NOT pre-fetch digital_assets, so every MAG record on a page triggers an extra prepared-statement query (100+ queries per page at PAGE_SIZE=100). ✓ fixed and verified (332f723)
F018 55 correctness app/Support/Updater.php:2245-2253 Path-traversal guard uses prefix-only comparison strpos($parentTarget, $realDest) !== 0; a target resolving to '/var/www/destination/x' passes when $realDest='/var/www/dest' because the prefix matches — original vulnerability persists. (human-confirmed)
F040 55 security storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2497-2524 requireAdminForDownload returns HTTP 403 when no session or Basic Auth credentials are present, instead of 401 with WWW-Authenticate; prevents HTTP clients from discovering that Basic Auth is supported. (human-confirmed)
F041 86 correctness storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:1147-1634 MARCXML formatter at line 1147-1148 uses date('YmdHis', $ts) (local timezone) for the MARC 005 control field; MODS formatter at line 1631-1634 uses date('Y-m-d', $ts) (local timezone) for recordCreationDate. Both should use gmdate() — the same file already uses gmdate() correctly in multiple places (e.g. line 436, 2371). For European installs (Europe/Rome: UTC+1/+2), exported MARCXML and MODS records carry local wall-clock time instead of UTC, causing off-by-one-to-two-hour drift in MARC 005 and off-by-one-day errors at midnight boundaries in recordCreationDate. ✓ fixed and verified (332f723)

Cross-cutting group G1: F007 + F041 — Both findings are manifestations of the same root cause: inconsistent local-timezone date emission inside OaiPmhServerPlugin.php. Fix together as a single 'normalize all catalog date emissions to UTC' pass: convert all five sites to strtotime()+gmdate() pattern (lines 596-601, 610-615 for F007; lines 1148, 1153, 1634 for F041), while explicitly preserving line 2211 (resumption-token expires_at). The UNIMARC sibling at line 1348 already uses gmdate() — use it as the canonical pattern throughout.

Details and fix proposals

F007 — earliestDatestamp in oaiIdentify uses DateTime::createFromFormat with no timezone — parses MySQL datetime in PHP's default TZ, then format('Y-m-d\TH:i:s\Z') appends Z literally; emitted datestamp is local time mislabelled as UTC. recordDatestamp() in the same file correctly uses strtotime+gmdate; this is the outlier.

File: storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:597-614
Score: 82 (strong)

Evidence:

  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:597 — DateTime::createFromFormat('Y-m-d H:i:s', $row['e']) with no timezone argument uses PHP date.timezone
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:599 — format('Y-m-dTH:i:sZ') uses escaped \Z (literal character), NOT UTC conversion — local wall-clock with literal 'Z' suffix
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:611-613 — identical bug duplicated for archival_units table
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2371-2377 — recordDatestamp() uses strtotime()+gmdate() correctly (UTC); earliestDatestamp is the outlier
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:436 — responseDate uses gmdate() correctly
  • git log confirms: pattern introduced by commit ce4237b on current PR branch (origin: introduced_by_pr confirmed)
  • Tests call verb=Identify but only check structure/well-formedness, not UTC correctness of earliestDatestamp value

Approach: Replace DateTime::createFromFormat (no timezone) + escaped \Z suffix with strtotime()+gmdate() pattern, matching the existing correct recordDatestamp() implementation. Apply to both the libri block (lines 596-601) and the archival_units block (610-615).

Files to modify:

  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php — Lines 596-601: replace 'if (!empty($row["e"])) { $dt = DateTime::createFromFormat(...); if ($dt !== false) { $earliest = $dt->format("Y-m-d\TH:i:s\Z"); } }' with 'if (!empty($row["e"])) { $ts = strtotime((string)$row["e"]); if ($ts !== false) { $earliest = gmdate("Y-m-d\TH:i:s\Z", $ts); } }'. Apply identical fix to lines 610-615 for archival_units block. (why: DateTime::createFromFormat without a timezone arg uses PHP date.timezone; the \Z in format() is an escaped literal character, not a UTC conversion — result is local wall-clock time with a literal 'Z' suffix, violating OAI-PMH §2.7.1 and breaking incremental harvesting for harvesters in non-UTC timezones)

Verification:

  • Set PHP default tz to Europe/Rome and a known libri.created_at='2026-01-15 10:00:00'; before fix: GET /oai?verb=Identify yields 2026-01-15T10:00:00Z (wrong — local wall clock); after fix: should match UTC equivalent
  • Cross-check: pick the book with earliest created_at, fetch GetRecord for it, compare its with Identify's — must be consistent
  • Add regression test asserting earliestDatestamp matches gmdate('Y-m-dTH:i:sZ', strtotime())

Edge cases to preserve:

  • Empty libri AND empty archival_units → static '1970-01-01T00:00:00Z' fallback must still apply
  • Only archival_units populated → archives min must still win and be UTC-correct
  • Both populated → lexicographic comparison of UTC strings remains semantically correct since both are now same format and timezone

Latest fix attempt (fixrun_01KRDDAGWPXE3XMZNNRSRW3ATQ): fixed and verified

F008 — bookDetail() issues SELECT 1 FROM plugins on every render — N+1 DB round-trip on the hottest page. No caching; runs even for anonymous catalog crawls.

File: app/Controllers/FrontendController.php:742-747
Score: 55
Reason: uncertain (Phase 4 inconclusive)
Human-confirmed: @fabiodalezbackup@gmail.com at 2026-05-12T08:08:08Z — Claim confirmed: uncached SELECT 1 FROM plugins on every bookDetail() render (hottest frontend page). Fix: add PluginManager::isActive() with static cache — eliminates copy-paste anti-pattern in FrontendController.php and layout.php.
Promoted from disposition=uncertain / actionability=auto_fixable / score_phase4=55
Fix direction: Add public function isActive(string $name): bool to PluginManager with private static array $cache = []. In isActive(): if isset($cache[$name]) return early, else SELECT is_active FROM plugins WHERE name=? LIMIT 1, cache result. Replace lines 742-747 in FrontendController.php with $bibframePluginActive = $this->container->get(PluginManager::class)->isActive('bibframe-linked-data'). Also fix layout.php:79 (identical uncached query for archives plugin). One DB hit per process lifetime per plugin.

F012 — writeBookMag calls fetchDigitalAsset per record — the batch path in fetchRecordsPage does NOT pre-fetch digital_assets, so every MAG record on a page triggers an extra prepared-statement query (100+ queries per page at PAGE_SIZE=100).

File: storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2500-2520
Score: 82 (strong)

Evidence:

  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:47 — PAGE_SIZE = 100
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:1740 — $asset = $this->fetchDigitalAsset((int)$row['id']) called unconditionally inside writeBookMag; no pre-fetched _digital_asset key check
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2337-2350 — fetchDigitalAsset() runs a fresh prepare/execute/close per call
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:1929-2192 — fetchRecordsPage() batch-fetches authors, publishers, genres, mag_config but never digital_assets
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2176 — comment 'Attach pre-fetched related data to avoid N+1 queries in writeMetadata()' — design intent is clear, digital_assets was missed
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:783 — 'mag' in validPrefixes for ListRecords — batch path is exercised in production

Approach: Add a 6th batch-fetch of digital_assets in fetchRecordsPage() alongside the existing 5 batched fetches; stamp _digital_asset on each row; update writeBookMag() to consume _digital_asset via array_key_exists() — mirroring the _authors/_publisher/_genre/_mag_config pattern already in the function.

Files to modify:

  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php — In fetchRecordsPage() after line ~2160 (existing genre batch): add batch SELECT of digital_assets WHERE libro_id IN ($ph) ORDER BY libro_id, id; build assetMap[$libroId]=first-row (ORDER BY libro_id, id + first-wins replicates existing LIMIT 1 ORDER BY id semantics). In reassembly loop (~line 2180): add $row['_digital_asset'] = $assetMap[$id] ?? null. In writeBookMag() line 1740: replace $asset=$this->fetchDigitalAsset((int)$row['id']) with $asset = array_key_exists('_digital_asset', $row) ? (is_array($row['_digital_asset']) ? $row['_digital_asset'] : null) : $this->fetchDigitalAsset((int)$row['id']) (why: writeBookMag() calls fetchDigitalAsset() per book (1 fresh prepare/execute/close per book), adding 100 DB round-trips per page of PAGE_SIZE=100 — defeating the explicit N+1-prevention batch-fetch pattern at line 2176)

Verification:

  • Enable mysqli general log; issue GET /oai?verb=ListRecords&metadataPrefix=mag; before fix: prepare/execute counter increments by ~105 per page; after fix: ~6 per page
  • Run E2E oai-pmh-server.spec.js with metadataPrefix=mag and confirm emitted XML is byte-identical to pre-fix output
  • Test edge cases: book with no digital_asset row (legacy file_url fallback), book with multiple digital_assets rows (must still pick lowest id)

Edge cases to preserve:

  • ORDER BY id LIMIT 1 semantics — when multiple digital_assets rows exist, pick the row with the smallest id (oldest insert); batch query must replicate with ORDER BY libro_id, id + first-wins
  • GetRecord (single record) and direct MAG download endpoint — those don't populate _digital_asset, so array_key_exists check must fall back to fetchDigitalAsset
  • Empty page with zero books — skip the new batch when $bookIds is empty

Latest fix attempt (fixrun_01KRDDAGWPXE3XMZNNRSRW3ATQ): fixed and verified

F018 — Path-traversal guard uses prefix-only comparison strpos($parentTarget, $realDest) !== 0; a target resolving to '/var/www/destination/x' passes when $realDest='/var/www/dest' because the prefix matches — original vulnerability persists.

File: app/Support/Updater.php:2245-2253
Score: 55
Reason: uncertain (Phase 4 inconclusive)
Human-confirmed: @fabiodalezbackup@gmail.com at 2026-05-12T08:20:41Z — CWE-22 prefix-without-separator confirmed: strpos(path, dest) matches '/var/www/destination' when dest='/var/www/dest'. Fix is trivial and future-proofs any new callers of copyDirectoryRecursive.
Promoted from disposition=uncertain / actionability=auto_fixable / score_phase4=55
Fix direction: Updater.php line 2249: replace strpos($parentTarget, $realDest) !== 0 with $parentTarget !== $realDest && !str_starts_with($parentTarget, $realDest . '/'). Add inline comment: // Append '/' to prevent prefix-collision: '/var/www/dest2' must not pass when $realDest='/var/www/dest'

F040 — requireAdminForDownload returns HTTP 403 when no session or Basic Auth credentials are present, instead of 401 with WWW-Authenticate; prevents HTTP clients from discovering that Basic Auth is supported.

File: storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2497-2524
Score: 55
Reason: uncertain (Phase 4 inconclusive)
Human-confirmed: @fabiodalezbackup@gmail.com at 2026-05-12T08:30:00Z — Confirmed RFC 7235 violation: 403 returned when no credentials present. Library automation clients (ILS, harvesters) need 401+WWW-Authenticate to discover Basic Auth support. Fix: differentiate no-credentials (401) from wrong-credentials (403).
Promoted from disposition=uncertain / actionability=auto_fixable / score_phase4=55
Fix direction: OaiPmhServerPlugin.php requireAdminForDownload(): split the failure path into two cases. If $request is null OR no Authorization header present: $out = $response->withStatus(401)->withHeader('WWW-Authenticate', 'Basic realm="OAI-PMH"'); return false. If Authorization header present but authenticateBasicOai fails: $out = $response->withStatus(403); return false. Check: $auth = $request !== null ? $request->getHeaderLine('Authorization') : ''; if empty($auth): return 401+WWW-Authenticate.

F041 — MARCXML formatter at line 1147-1148 uses date('YmdHis', $ts) (local timezone) for the MARC 005 control field; MODS formatter at line 1631-1634 uses date('Y-m-d', $ts) (local timezone) for recordCreationDate. Both should use gmdate() — the same file already uses gmdate() correctly in multiple places (e.g. line 436, 2371). For European installs (Europe/Rome: UTC+1/+2), exported MARCXML and MODS records carry local wall-clock time instead of UTC, causing off-by-one-to-two-hour drift in MARC 005 and off-by-one-day errors at midnight boundaries in recordCreationDate.

File: storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:1147-1634
Score: 86 (strong)

Evidence:

  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:1147-1148 — MARC21XML field 005 uses date('YmdHis', $ts), i.e. PHP local timezone; MARC 21 spec treats 005 as UTC
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:1153 — MARC 008 byte 0-5 uses date('ymd'), also local-timezone
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:1631-1634 — MODS recordCreationDate uses date('Y-m-d', $ts), local-timezone with encoding='w3cdtf'
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:1348 — UNIMARC equivalent of MARC 005 correctly uses gmdate('YmdHis') — confirms intended UTC convention, not stylistic choice
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:1361, 1462, 2587, 2592, 2656 — all other MARC date emissions use gmdate(), establishing UTC as the file's convention
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:436, 902, 2371-2376 — OAI-PMH response envelope timestamps all use gmdate(), spec-compliant

Approach: Replace three local-timezone date() calls with gmdate() in MARC21XML and MODS formatters. Lines 1148, 1153, 1634 only. Do NOT change line 2211 (resumption-token expires_at — paired with MySQL NOW()).

Files to modify:

  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php — Line 1148: date('YmdHis', $ts) → gmdate('YmdHis', $ts) for MARC 005. Line 1153: date('ymd') → gmdate('ymd') for MARC 008 date-entered-on-file. Line 1634: date('Y-m-d', $ts) → gmdate('Y-m-d', $ts) for MODS recordCreationDate. (why: UNIMARC sibling at line 1348 already uses gmdate(); MARC 005 is conventionally UTC; MODS w3cdtf encoding implies UTC-derived date; local-tz emits wrong date at midnight and wrong time for non-UTC servers)

Verification:

  • Set PHP timezone to Europe/Rome; GET /oai?verb=GetRecord&identifier=oai:HOST:libri:1&metadataPrefix=marc21 — confirm equals UTC value of updated_at, not local time
  • Same with metadataPrefix=mods — confirm equals UTC date of created_at
  • Cross-check: OAI-PMH header (already UTC) and embedded MARC 005 / MODS recordCreationDate should now agree on the date component
  • Run existing OAI-PMH E2E specs to ensure no timestamp assertions break

Edge cases to preserve:

  • Books with NULL updated_at/created_at: strtotime() → false → ?: time() fallback works identically with gmdate()
  • Do NOT change line 2211 (resumption-token expires_at) — paired with MySQL NOW()
  • Format strings stay identical; only function name changes

Latest fix attempt (fixrun_01KRDDAGWPXE3XMZNNRSRW3ATQ): fixed and verified

Light lane — ux, policy, architecture

Light-lane findings — including rows labeled auto-fixable — aren't applied by /adamsreview:fix directly. Findings with an auto-recommendation get batch-confirmed at :fix's Phase 7.5 preflight (or :walkthrough Step 4.5); use /adamsreview:promote <finding_id> for a single-finding manual override.

# Score Impact File Finding Disposition
F029 55 ux app/Views/frontend/layout.php:1924 The archive icon inside each search-dropdown result item lacks aria-hidden="true", causing screen readers to announce a meaningless icon in addition to the label text. (human-confirmed) auto-fixable
F033 50 ux app/Views/frontend/book-detail.php:1608-1612 The media-type icon inside the newly added badge lacks aria-hidden="true", so screen readers announce the icon class name alongside the display label. (human-confirmed) auto-fixable
Promoted findings — details

F029 — The archive icon inside each search-dropdown result item lacks aria-hidden="true", causing screen readers to announce a meaningless icon in addition to the label text.

File: app/Views/frontend/layout.php:1924
Score: 55
Reason: uncertain (Phase 4 inconclusive)
Human-confirmed: @fabiodalezbackup@gmail.com at 2026-05-12T08:25:01Z — Confirmed: fas fa-archive icon in JS search dropdown has no aria-hidden — announced on every keypress. Project convention is aria-hidden=true on all decorative FA icons (25+ instances).
Promoted from disposition=uncertain / actionability=auto_fixable / score_phase4=55
Fix direction: layout.php line 1925 JS string: change '' to ''

F033 — The media-type icon inside the newly added badge lacks aria-hidden="true", so screen readers announce the icon class name alongside the display label.

File: app/Views/frontend/book-detail.php:1608-1612
Score: 50
Reason: uncertain (Phase 4 inconclusive)
Human-confirmed: @fabiodalezbackup@gmail.com at 2026-05-12T08:25:01Z — Confirmed: media-type icon in book-detail badge has no aria-hidden — screen readers announce icon class name alongside label. Inconsistent with book-detail.php lines 1638 and 1263 which already use aria-hidden=true correctly.
Promoted from disposition=uncertain / actionability=auto_fixable / score_phase4=50
Fix direction: book-detail.php line 1610: add aria-hidden="true" to <i class="fas ..." — change to

Fix runs

Run fixrun_01KRDDAGWPXE3XMZNNRSRW3ATQ — 2026-05-12T07:22:42Z

  • Outcomes: 5 fixed and verified
  • Commits: 332f723
Finding Group Outcome phase_9_finding
F007 FG-1 ✓ fixed and verified
F012 FG-1 ✓ fixed and verified
F031 FG-2 ✓ fixed and verified
F034 FG-3 ✓ fixed and verified
F041 FG-1 ✓ fixed and verified

🤖 Generated with Adam's Claude Code Review Command

@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

Auto-recommendation acceptance

rev_01KRCFVJ2J4S9RJ989JXT3FJY5 · run_id=fixrun_01KRDDAGWPXE3XMZNNRSRW3ATQ · choice=apply_all · threshold=60 · reviewer=auto-rec/fabiodalezbackup@gmail.com · ts=2026-05-12T07:09:43Z

2 auto-rec finding(s) eligible at threshold ≥ 60. Of those, 2 auto-promoted via batch, 0 promoted with an edited or alternative hint, 0 skipped per-finding.

Promoted (batch)

  • F031 — The 'Vedi tutti i risultati' link text in the search dropdown is hardcoded Italian and not wrapped in __(), breaking the English/German/French locale support introduced by this same PR.
    • Hint: Replace the hardcoded Italian string 'Vedi tutti i risultati'on line 1938 with a PHP-injected translated value. Following the established pattern in the same file (e.g., lines 2074–2075), declare a JS variable above the search handler:const searchViewAllLabel = ;, then reference it in the concatenated HTML string as searchViewAllLabelin place of the literal. The translation key'Vedi tutti i risultati' must also be added to all locale files (locale/en_US.json, locale/de_DE.json, locale/fr_FR.json) with the appropriate equivalents.
  • F034 — The delete-server button in Z39 server rows is icon-only with no aria-label, making it inaccessible for screen-reader users who cannot determine what the trash icon will delete.
    • Hint: Add an aria-labelattribute to the delete button at line 1122 ofplugins.php. Since the button is generated inside a JS template literal, inject a PHP translated string as a JS variable before the addZ39Serverfunction:const z39DeleteServerLabel = ;, then change the button tag to . This follows the same injection pattern used for other translated labels in the file.

Auto-recommendation acceptance: append-only audit. Each /adamsreview:fix run posts a fresh entry. Promoted findings are now confirmed_mechanical with human_confirmation set; Phase 8 dispatches them next.

Fix groups (committed):
- [FG-1] F007, F041, F012 — storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php: verified
- [FG-2] F031 — app/Views/frontend/layout.php, locale/en_US.json, locale/de_DE.json, locale/fr_FR.json, locale/it_IT.json: verified
- [FG-3] F034 — app/Views/admin/plugins.php: verified

Post-fix review: 3/3 groups verified complete; 0 group(s) partial; 0 group(s) reverted.
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

Walkthrough decisions — 2026-05-12T08:05:17Z

Reviewer: fabiodalezbackup@gmail.com
Scope: Qualifying (5 findings, threshold=50)
Review: rev_01KRCFVJ2J4S9RJ989JXT3FJY5

Finding Action Reason
F008 (deep/correctness, score=55) Promoted Uncached SELECT 1 FROM plugins on every bookDetail() render. Fix: PluginManager::isActive() with static cache.
F018 (deep/correctness, score=55) Promoted CWE-22 prefix-without-separator in Updater path-traversal guard. Fix: append '/' to $realDest comparison + comment.
F029 (light/ux, score=55) Promoted Decorative archive icon in search dropdown missing aria-hidden="true". Inconsistent with 25+ correct usages in codebase.
F033 (light/ux, score=50) Promoted Decorative media-type icon in book-detail badge missing aria-hidden="true".
F040 (deep/security, score=55) Promoted requireAdminForDownload returns 403 instead of 401+WWW-Authenticate. Breaks RFC 7235 — ILS/harvester clients can't discover Basic Auth. Fix: differentiate no-credentials (401) from wrong-credentials (403).

Summary: 5 promoted, 0 skipped. Run /adamsreview:fix to apply all 5 fixes.

🤖 Generated with Adam's Claude Code Review Command

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants