Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
47352bd
feat(desiderata): books the library is looking for, and donations fro…
fabiodalez-dev Sep 16, 2026
91ce48a
fix(desiderata): hide requests everywhere the public can reach, and m…
fabiodalez-dev Sep 16, 2026
d50fd97
fix(desiderata): stop the wanted-list leaking through the seams aroun…
fabiodalez-dev Sep 17, 2026
6e0cbe6
feat(desiderata): make the wanted list a part of the library, not a p…
fabiodalez-dev Sep 17, 2026
529cc5f
ci: keep the workflow linters honest after the requires_app guard mov…
fabiodalez-dev Sep 17, 2026
9d6d1aa
feat(desiderata): tell the operators when a book is offered and when …
fabiodalez-dev Sep 17, 2026
ca43dc4
fix: close the review findings around the wanted list
fabiodalez-dev Sep 17, 2026
4d6ef42
feat(desiderata): let a reader reach the book, page through the list,…
fabiodalez-dev Sep 17, 2026
272005d
test(desiderata): 154 checks over the behaviour this branch added
fabiodalez-dev Sep 17, 2026
0525bc1
test(desiderata): the nine browser tests, and the defect the first of…
fabiodalez-dev Sep 17, 2026
3f5e1a1
fix(i18n): the Danish receipt notification said inventory, not catalogue
fabiodalez-dev Sep 17, 2026
bc48fb8
fix: the locale gate misread JS comments, and a cleanup could swallow…
fabiodalez-dev Sep 17, 2026
9923296
test: a cleanup that dies part-way must not erase the result it was r…
fabiodalez-dev Sep 17, 2026
9281dbd
test: a check that described the developer's machine, and one that wo…
fabiodalez-dev Sep 17, 2026
e9730ea
fix(desiderata): keep the request until the copies exist, and anchor …
fabiodalez-dev Sep 17, 2026
47d098a
chore(release): cut 0.7.86, register desiderata at install, ship book…
fabiodalez-dev Sep 17, 2026
85d7073
test: the ConfigStore precondition described a database, not a behaviour
fabiodalez-dev Sep 17, 2026
7bc704a
fix(desiderata): stop the wanted flag from breaking things outside it…
fabiodalez-dev Sep 17, 2026
1e67740
test(desiderata): stop the dashboard check from asserting a property …
fabiodalez-dev Sep 17, 2026
92e1a1e
docs(visibility): record why the desiderata column probe may be memoised
fabiodalez-dev Sep 17, 2026
fa2fdcf
fix(desiderata): apply the four findings promoted in the walkthrough
fabiodalez-dev Sep 17, 2026
3e28b33
fix(desiderata): stop the thank-you banner depending on a session the…
fabiodalez-dev Sep 17, 2026
8f3153e
test(desiderata): follow the thank-you marker into the two assertions…
fabiodalez-dev Sep 17, 2026
c52e039
fix(forms): show a locked field as locked, and stop three browser che…
fabiodalez-dev Sep 17, 2026
504b93c
test(book-club): stop R16 passing when the declined loan is deleted i…
fabiodalez-dev Sep 17, 2026
305ef6e
fix(desiderata): stop the homepage donation form minting its session …
fabiodalez-dev Sep 18, 2026
5e4ede3
feat(desiderata): let a donor send from a browser the site never gave…
fabiodalez-dev Sep 18, 2026
76fb354
fix(desiderata): serialize CSRF initialization and recover interrupte…
fabiodalez-dev Sep 18, 2026
83e2d54
fix(desiderata): stamp catalogued_at on every insert, fold CSV header…
fabiodalez-dev Sep 18, 2026
24c13c0
test(desiderata): make sandbox suites order-independent and never lea…
fabiodalez-dev Sep 18, 2026
a890f15
fix(desiderata): close the remaining review findings
fabiodalez-dev Sep 18, 2026
757ea10
fix(auth): re-validate the session role on every protected request
fabiodalez-dev Sep 18, 2026
52fff5e
fix: close the remaining review findings and the bugs found while doc…
fabiodalez-dev Sep 18, 2026
67bb024
docs(hooks): document every hook core invokes and correct stale refer…
fabiodalez-dev Sep 18, 2026
e3d61f1
fix: address the CodeRabbit review of 76fb3547
fabiodalez-dev Sep 19, 2026
6364b18
fix(ui): keep typed search text across activity feed swaps, stop fals…
fabiodalez-dev Sep 19, 2026
464bb40
fix(auth): decide admin access on the re-validated role, so promotion…
fabiodalez-dev Sep 19, 2026
9342882
fix: address the CodeRabbit review of 6364b18d
fabiodalez-dev Sep 19, 2026
14d5b8c
test: keep "0" in the sandbox guard's list of real database names
fabiodalez-dev Sep 19, 2026
24bb009
Merge remote-tracking branch 'origin/main' into fix/desiderata-donazioni
fabiodalez-dev Sep 24, 2026
3870484
fix(desiderata): a failed proposals query is not an empty list
fabiodalez-dev Sep 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/ci-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,36 @@ jobs:
done
[ "$FAILED" -eq 0 ] && echo "✓ Tutte le migration hanno versione ≤ $TARGET" || exit 1

# ── Plugin requires_app guard ─────────────────────────────────────────
# Same version_compare ordering hazard as the migration guard, one layer
# up: PluginManager refuses to activate a bundled plugin whose
# requires_app is above version.json, so the plugin ships registered,
# visible and permanently impossible to enable. An empty or absent
# requires_app means "no floor" and is skipped, not failed.
- name: Plugin requires_app guard (bundled plugin.json ≤ version.json)
run: |
TARGET=$(php -r "echo json_decode(file_get_contents('version.json'))->version;")
FAILED=0
for f in storage/plugins/*/plugin.json; do
[ -f "$f" ] || continue
plugin_name="$(basename "$(dirname "$f")")"
# The single quotes below are load-bearing: $m is a PHP variable and
# must reach php unexpanded. The manifest path travels through the
# environment (getenv), so nothing here needs shell interpolation.
# shellcheck disable=SC2016
REQ=$(MANIFEST="$f" php -r '
$m = json_decode((string)file_get_contents(getenv("MANIFEST")), true);
echo is_array($m) && isset($m["requires_app"]) ? (string)$m["requires_app"] : "";
')
[ -n "$REQ" ] || continue
if ! REQUIRES_APP="$REQ" RELEASE_VERSION="$TARGET" php -r \
'exit(version_compare(getenv("REQUIRES_APP"), getenv("RELEASE_VERSION"), "<=") ? 0 : 1);'; then
echo " ✗ ${plugin_name}: requires_app $REQ > $TARGET (il plugin non potrebbe mai essere attivato)"
FAILED=1
fi
done
[ "$FAILED" -eq 0 ] && echo "✓ Tutti i plugin bundled hanno requires_app ≤ $TARGET" || exit 1

# DB-backed unit tests (migration-*, book-field-types, loan-edge-cases,
# session-fixes) read credentials from .env and connect over TCP. Provide
# a CI .env pointing at the MySQL service and load the base schema so the
Expand Down Expand Up @@ -306,6 +336,21 @@ jobs:
php tests/plugin-zip-update.integration.php
php tests/plugin-zip-update-all-bundled.integration.php

# The desiderata feature's advertised coverage: the lifecycle/offer suite
# and the interop-visibility contract (mobile-api, OAI-PMH, SRU, NCIP,
# OpenURL, BIBFRAME). Neither ran in any workflow before, so 400+ lines of
# regression checks executed only if a developer remembered to. Strict
# mode turns any remaining skip into a failure.
- name: PHP desiderata integration suites
env:
CI_STRICT_TESTS: '1'
run: |
php tests/desiderata.integration.php
php tests/desiderata-visibility.integration.php
php tests/desiderata-extended.integration.php
php tests/desiderata-recaptcha.unit.php
php tests/desiderata-core-hooks.unit.php

- name: Shell test — bin/setup-permissions.sh
run: bash tests/setup-permissions.test.sh

Expand Down
2 changes: 1 addition & 1 deletion .github/zizmor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@ rules:
# Line-pinned on purpose: if the steps move, the finding resurfaces
# instead of being silently masked for the whole file.
- ci-quality.yml:117
- ci-quality.yml:361
- ci-quality.yml:406
- release.yml:61
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ storage/calendar/*
!storage/calendar/.htaccess
!storage/plugins/
storage/plugins/*
!storage/plugins/desiderata/
storage/plugins/desiderata/*
!storage/plugins/desiderata/*.php
!storage/plugins/desiderata/*.json
!storage/plugins/desiderata/*.md
!storage/plugins/desiderata/views/
!storage/plugins/desiderata/views/*.php
!storage/plugins/open-library/
storage/plugins/open-library/*
!storage/plugins/open-library/*.php
Expand Down
17 changes: 17 additions & 0 deletions app/Controllers/AutoriApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,23 @@ public function list(Request $request, Response $response, mysqli $db): Response
}

$selectNaz = $colNaz !== null ? "a.`$colNaz` AS nazionalita" : "'' AS nazionalita";
// The libri_count below is deliberately UNFILTERED by BookVisibility,
// following the operator-counts rule: /admin/books, DashboardStats and
// the header quick-stat all count every record, requests (desiderata)
// among them. Filtering here alone would make the "N. Libri" column
// disagree with the list an operator reaches by clicking it, by exactly
// the number of wanted titles, with nothing on screen explaining the gap.
// The public-facing twin, SearchController::searchAuthorsWithDetails(),
// is the one that carries the catalogue filter.
//
// "Operator surface" is now an enforced audience and not just an
// intended one: GET /api/autori used to be the one registration in its
// block chaining no AdminAuthMiddleware while its bulk-delete and
// bulk-export siblings did, which left this SELECT's biografia,
// sito_web and life dates readable by anyone (CWE-306). The route
// carries the middleware now — the fix belonged there, not in this
// count, because filtering the count would have papered over the
// exposure while breaking the operator's arithmetic.
$sql_prepared = "SELECT a.id, a.nome, a.pseudonimo, a.data_nascita, a.data_morte, a.biografia, a.sito_web,
$selectNaz,
(SELECT COUNT(DISTINCT la.libro_id)
Expand Down
14 changes: 14 additions & 0 deletions app/Controllers/CmsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,20 @@ public function updateHome(Request $request, Response $response, \mysqli $db, ar
}
}

// Plugin-owned sections persist their own fields from here and may add
// errors. Filter contract: return the (possibly extended) $errors array,
// and write only when the incoming array is empty — the same "one bad
// field discards the whole submission" rule every core block above obeys.
// HookManager::applyFilters() swallows a handler's throwable and keeps
// the unfiltered value, so a handler that lets one escape would report a
// successful save having written nothing: handlers catch their own.
// The guards below keep a misbehaving handler (wrong type, non-string
// entries) from breaking the page instead of just its own section.
$filtered = \App\Support\Hooks::apply('cms.home.save', $errors, [$data]);
if (is_array($filtered)) {
$errors = array_values(array_filter($filtered, 'is_string'));
}

if (!empty($errors)) {
// Every section above is written only `if (... && empty($errors))`,
// so one invalid field discards the whole submission — including
Expand Down
58 changes: 45 additions & 13 deletions app/Controllers/CsvImportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -1145,7 +1145,9 @@ private function parseCsvRow(array $row): array
'colorista_provided' => array_key_exists('colorista', $row),
'parole_chiave' => !empty($row['parole_chiave']) ? trim($row['parole_chiave']) : null,
'classificazione_dewey' => !empty($row['classificazione_dewey']) ? trim($row['classificazione_dewey']) : null,
'copertina_url' => !empty($row['copertina_url']) ? trim($row['copertina_url']) : null
'copertina_url' => !empty($row['copertina_url']) ? trim($row['copertina_url']) : null,
// A request: the library wants the book but owns no copy of it.
'is_desiderata' => in_array(strtolower(trim((string) ($row['is_desiderata'] ?? ''))), ['1', 'true', 'yes', 'si', 'sì', 'y'], true)
];
}

Expand Down Expand Up @@ -1420,7 +1422,11 @@ private function mapColumnHeaders(array $headers): array
'curatore' => ['curatore', 'editor', 'curator', 'edited by', 'herausgeber'],
'colorista' => ['colorista', 'colorist', 'colourist'],
'parole_chiave' => ['parole_chiave', 'parole chiave', 'keywords', 'tags', 'palabras clave', 'mots-clés', 'schlagwörter', 'subjects'],
'classificazione_dewey' => ['classificazione_dewey', 'dewey', 'dewey decimal', 'dewey classification', 'dewey wording', 'lc classification', 'call number', 'other call number']
'classificazione_dewey' => ['classificazione_dewey', 'dewey', 'dewey decimal', 'dewey classification', 'dewey wording', 'lc classification', 'call number', 'other call number'],
// Written by the standard export on installations with the
// desiderata plugin; harmless everywhere else, where the value is
// simply ignored because the column does not exist.
'is_desiderata' => ['is_desiderata', 'desiderata', 'wanted', 'wunschbuch', 'recherché', 'ønsket']
];

$mappedHeaders = [];
Expand Down Expand Up @@ -1707,6 +1713,12 @@ private function findExistingBook(\mysqli $db, array $data): ?int
* description → descrizione/descrizione_plain; keywords → parole_chiave;
* contributors → the legacy traduttore/illustratore/curatore TEXT columns.
*
* is_desiderata is deliberately NOT written here. An import that matches an
* existing record must never turn a holding back into a request: the core
* invariant is that a book with physical copies is not a desiderata, and
* DataIntegrity would clear the flag on the very next recalculation anyway.
* The flag is therefore set on the insert path only.
*
* @param array<string,bool> $updateFields
*/
private function updateBook(\mysqli $db, int $bookId, array $data, ?int $editorId, ?int $genreId, array $updateFields = []): void
Expand Down Expand Up @@ -2065,20 +2077,27 @@ private function insertBook(\mysqli $db, array $data, ?int $editorId, ?int $genr
$hasDescPlain = $this->hasDescrizionePlainColumn($db);
$descPlainCol = $hasDescPlain ? ', descrizione_plain' : '';
$descPlainVal = $hasDescPlain ? ', ?' : '';
// A flagged row is a book the library does NOT own. Writing the flag
// without also suppressing the copies below would be self-erasing: the
// availability recalculation at the end of this method clears the flag
// the moment a copy exists.
$wanted = \App\Support\BookVisibility::hasDesiderata($db) && !empty($data['is_desiderata']);
$desiderataCol = $wanted ? ', is_desiderata' : '';
$desiderataVal = $wanted ? ', 1' : '';

$stmt = $db->prepare("
INSERT INTO libri (
isbn10, isbn13, ean, titolo, sottotitolo, anno_pubblicazione,
lingua, edizione, numero_pagine, genere_id,
descrizione{$descPlainCol}, formato{$tipoMediaCol}, prezzo, copie_totali, copie_disponibili,
editore_id, collana, numero_serie, traduttore, illustratore, curatore, parole_chiave,
classificazione_dewey, stato, created_at
classificazione_dewey, stato, created_at{$desiderataCol}
) VALUES (
?, ?, ?, ?, ?, ?,
?, ?, ?, ?,
?{$descPlainVal}, ?{$tipoMediaVal}, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?,
?, 'disponibile', NOW()
?, 'disponibile', NOW(){$desiderataVal}
)
");

Expand All @@ -2105,7 +2124,15 @@ classificazione_dewey, stato, created_at
// dropped copies on a cross-install migration (export writes the real
// count); raise it to a still-DoS-safe ceiling and log when it bites so a
// truncation is diagnosable rather than invisible.
if ($copie < 1) {
//
// The "< 1 becomes 1" clamp is corrected for FLAGGED ROWS ONLY: an
// exported request carries copie_totali = 0, and a numeric zero falling
// through that clamp is what fabricates a physical copy and an inventory
// code for a book the library does not own. Ordinary zero-copy imports
// keep the existing behaviour on purpose.
if ($wanted) {
$copie = 0;
} elseif ($copie < 1) {
$copie = 1;
} elseif ($copie > 2000) {
\App\Support\SecureLogger::warning('CsvImportController: copie_totali troncato all\'import', [
Expand Down Expand Up @@ -2160,16 +2187,21 @@ classificazione_dewey, stato, created_at
$this->syncImportedSeries($db, $bookId, $collana, $numeroSerie);
$this->syncPrimaryPublisherJunction($db, $bookId, $editorId);

// Genera copie fisiche nella tabella copie
$copyRepo = new \App\Models\CopyRepository($db);
// Genera copie fisiche nella tabella copie.
// A request gets none: the whole point is that no copy exists yet. The
// availability recalculation below still runs, so the row lands in a
// consistent state (stato 'non_disponibile', zero counters).
if (!$wanted) {
$copyRepo = new \App\Models\CopyRepository($db);

// Genera numero inventario base (usa ISBN se disponibile, altrimenti LIB-{id})
$baseInventario = $isbn13 ?: ($isbn10 ?: "LIB-{$bookId}");
// Genera numero inventario base (usa ISBN se disponibile, altrimenti LIB-{id})
$baseInventario = $isbn13 ?: ($isbn10 ?: "LIB-{$bookId}");

// Batch: one prefix pre-load + one multi-row INSERT instead of two
// queries per copy, so a large copie_totali doesn't flood the per-row
// transaction with thousands of statements.
$copyRepo->createManyForBook($bookId, $baseInventario, $copie, 'disponibile', __("Copia %d di %d"));
// Batch: one prefix pre-load + one multi-row INSERT instead of two
// queries per copy, so a large copie_totali doesn't flood the per-row
// transaction with thousands of statements.
$copyRepo->createManyForBook($bookId, $baseInventario, $copie, 'disponibile', __("Copia %d di %d"));
}

// Ricalcola disponibilità dopo aver creato le copie
$integrity = new \App\Support\DataIntegrity($db);
Expand Down
2 changes: 1 addition & 1 deletion app/Controllers/FeedController.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ private function getLatestBooks(mysqli $db, string $baseUrl): array
e.nome AS editore
FROM libri l
LEFT JOIN editori e ON l.editore_id = e.id
WHERE l.deleted_at IS NULL
WHERE l.deleted_at IS NULL AND " . \App\Support\BookVisibility::catalogue($db, 'l') . "
ORDER BY l.created_at DESC
LIMIT 50
";
Expand Down
Loading
Loading