Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
746c913
fix(release): harden plugin updates and package sessions
fabiodalez-dev Aug 19, 2026
92898b9
fix(plugins): retire unreadable update markers and harden verifiers
fabiodalez-dev Aug 19, 2026
34a7169
Merge pull request #364 from fabiodalez-dev/codex/fix-0.7.62-release-…
fabiodalez-dev Aug 19, 2026
226df25
docs(changelog): note active-plugin update lifecycle and verifier har…
fabiodalez-dev Aug 19, 2026
bc4f05e
fix(release): ship storage/sessions/.gitkeep and stabilise plugin-upd…
fabiodalez-dev Aug 19, 2026
bbaf1a0
fix(plugins): address review findings on the deferred-update lifecycle
fabiodalez-dev Aug 19, 2026
0440150
fix(plugins): unlink orphaned marker on lock failure and harden test …
fabiodalez-dev Aug 19, 2026
f9656b1
fix(plugins,release): savepoint tx probe and reject symlinked session…
fabiodalez-dev Aug 19, 2026
80a69a4
feat(loans): per-row quick recall action on the loans list (#360)
fabiodalez-dev Aug 19, 2026
f04132a
fix(plugins,release): unique per-invocation savepoints and full-path …
fabiodalez-dev Aug 20, 2026
af369bc
fix(plugins): create the deferred-update marker before promoting the …
fabiodalez-dev Aug 20, 2026
f131d5d
fix(loans): don't mark a book ready-for-pickup while a copy is still …
fabiodalez-dev Aug 20, 2026
e18a903
fix(loans): lock-first ordering in circulation transactions to end MV…
fabiodalez-dev Aug 20, 2026
bbab2a6
fix(loans): harden the full #366 reschedule/overdue scenario end-to-end
fabiodalez-dev Aug 20, 2026
36da980
fix(loans): close the remaining reservation/loan edge cases from the …
fabiodalez-dev Aug 20, 2026
03b718c
docs(changelog): document the reservation/loan edge-case fixes in 0.7.62
fabiodalez-dev Aug 20, 2026
025e79c
fix(tests,release): address review findings on the audit-fix tests an…
fabiodalez-dev Aug 20, 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
2 changes: 2 additions & 0 deletions .distignore
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ fix-*.php
# Storage/uploads (exclude user data, keep directory structure)
storage/logs/*
storage/cache/*
storage/sessions/*
storage/backups/*
storage/uploads/*
storage/tmp/*
Expand All @@ -87,6 +88,7 @@ cache/*
# Keep essential files and directory structure
!storage/logs/.gitkeep
!storage/cache/.gitkeep
!storage/sessions/.gitkeep
!storage/backups/.gitkeep
!storage/backups/.htaccess
!storage/uploads/.gitkeep
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ storage/*
!storage/logs/
storage/logs/*
!storage/logs/.gitkeep
!storage/sessions/
storage/sessions/*
!storage/sessions/.gitkeep
storage/*.log
!storage/tmp/
storage/tmp/*
Expand Down
3 changes: 3 additions & 0 deletions .rsync-filter
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
+ storage/logs/.gitkeep
+ storage/cache/
+ storage/cache/.gitkeep
+ storage/sessions/
+ storage/sessions/.gitkeep
+ storage/backups/
+ storage/backups/.gitkeep
+ storage/backups/.htaccess
Expand Down Expand Up @@ -254,6 +256,7 @@
# Storage user data (structure already included above)
- /storage/logs/*
- /storage/cache/*
- /storage/sessions/*
- /storage/backups/*
- /storage/tmp/*
- /storage/calendar/*
Expand Down
47 changes: 44 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ Overdue-loan recalls (solleciti) and emailing the loan receipt (#360).
single overdue notification. Automatic recalls repeat at a configurable
interval up to a configurable cap (Settings → Loans → "Solleciti automatici",
off by default; sent by the notifications cron or on admin login). Staff can
also send a manual recall for one loan from the loan detail page, or for many
at once from the loans list via the bulk action bar — manual recalls ignore
also send a manual recall for one loan — from the loan detail page or a
per-row action on the loans list — or for many at once from the loans list
via the bulk action bar — manual recalls ignore
the automatic schedule but share the same per-loan counter
(`prestiti.recall_count` / `last_recall_at`, added by
`migrate_0.7.62-rc.1.sql` and self-healed at runtime). New editable email
Expand All @@ -40,14 +41,54 @@ Overdue-loan recalls (solleciti) and emailing the loan receipt (#360).
- **Plugin ZIP updates (#358)**: uploading a plugin ZIP whose name matches an
already-installed plugin now updates it in place — its id, settings, data and
hooks are preserved and its files are swapped atomically — instead of failing
on the existing directory. Covered by contract and per-bundled-plugin
on the existing directory. Updating an already-active plugin now runs its new
lifecycle (`onActivate()`/`ensureSchema()`) on the next request via a
pending-update marker, rolling back package, metadata and hooks if the new
version fails to activate — so schema changes shipped in an update are applied
instead of being silently skipped. Covered by contract and per-bundled-plugin
integration tests.

### Fixed

- **Book announced "ready for pickup" while still on an overdue loan (#366)**:
a reservation scheduled right after a loan that then went overdue and was
never returned was promoted to `da_ritirare` on its date alone, emailing the
next patron a wrong "ready for pickup" notice while the book was still out.
Promotion is now gated on a copy being physically free (active loans below the
copy count; a pinned copy must be on the shelf). The full reported sequence is
covered too: rescheduling an open reservation/pickup no longer leaves a stale
`pickup_deadline` for the expiry sweep to cull a valid loan against, the
overdue flip now runs first in the maintenance pass so an unreturned overdue
loan keeps holding its copy, and `renew()` refuses a date-overdue loan.
- **Concurrent circulation actions no longer corrupt state**: eight
transactions (approve/return/reject/cancel loan and reservation) resolved the
loan id with a plain read before taking the book lock, so under REPEATABLE
READ their later reads were blind to a competitor that had just committed.
A just-cancelled reservation could be promoted and emailed, and one physical
copy could be committed to two loans. The lookups now run before the
transaction so the first locked read fixes the snapshot, and reservation
promotion claims the row with a state-guarded update.
- **Pickup confirmation** now refuses a copy that is still out on another loan
(`prestato`), preventing a double issue of the same physical copy.
- **Admin reservation cancellation** now promotes the next reservation in the
queue immediately, like every other path that frees a copy.
- **Overdue notices and automatic recalls** now fire for loans whose book has
been archived (soft-deleted) — the chase-up mail no longer filters those out.
- **The "reservation available" email** can no longer be sent twice when the
retry sweep races the request that promoted it.
- Admin direct loans cap the pickup deadline at the due date; bulk loan
extension and reschedules re-check borrower eligibility; expiry audit notes
use the same day the decision was made near midnight.

### Internal

- CI: the OWASP ZAP baseline no longer fails on the ISBN/EAN-13 PII-disclosure
false positive, allowlisted narrowly to 13-digit codes on bibliographic pages
(#359).
- Release verifiers now require `storage/sessions/.gitkeep` and reject every
other entry there (files, symlinks, stray directories), and an unreadable
plugin-update marker is retired instead of permanently blocking future
updates of that plugin.

## [0.7.61]

Expand Down
108 changes: 72 additions & 36 deletions app/Controllers/LoanApprovalController.php
Original file line number Diff line number Diff line change
Expand Up @@ -171,19 +171,19 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R
}

try {
$db->begin_transaction();

// ORDINE DI LOCK CANONICO (P3): la riga `libri` per prima, poi `prestiti`.
// Determiniamo il libro del prestito con una lettura NON bloccante, poi
// acquisiamo i lock nell'ordine libri -> prestiti come tutti gli altri
// entry point, evitando deadlock da lock-order inversion.
// Determiniamo il libro del prestito con una lettura NON bloccante PRIMA
// di aprire la transazione (lock-first, come update()/renew()): sotto
// REPEATABLE READ la read view nasce alla prima consistent read della
// transazione, e farla nascere prima del lock renderebbe ogni SELECT
// non bloccante successiva cieca ai commit concorrenti avvenuti mentre
// aspettavamo il lock del libro.
$bookLookup = $db->prepare("SELECT libro_id FROM prestiti WHERE id = ? AND stato = 'pendente'");
$bookLookup->bind_param('i', $loanId);
$bookLookup->execute();
$bookRow = $bookLookup->get_result()->fetch_assoc();
$bookLookup->close();
if (!$bookRow) {
$db->rollback();
$response->getBody()->write(json_encode([
'success' => false,
'message' => __('Prestito non trovato o già processato')
Expand All @@ -192,8 +192,11 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R
}
$libroId = (int) $bookRow['libro_id'];

$db->begin_transaction();

// Lock della riga `libri` PRIMA — serializza anche le approvazioni dello
// stesso libro (CONC-03).
// stesso libro (CONC-03) — ed è la PRIMA statement della transazione,
// così la read view viene creata solo a lock acquisito (post-competitor).
$lockBookStmt = $db->prepare("SELECT id FROM libri WHERE id = ? AND deleted_at IS NULL FOR UPDATE");
$lockBookStmt->bind_param('i', $libroId);
$lockBookStmt->execute();
Expand Down Expand Up @@ -595,29 +598,32 @@ public function rejectLoan(Request $request, Response $response, mysqli $db): Re
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
}

// Canonical lock order: resolve the book without locking, lock `libri`
// first, then lock the pending loan. DataIntegrity locks the same book
// during the availability recalculation; taking the loan first here
// inverted the order used by approval/return and could deadlock.
// Lock-first (MVCC): the lookup runs BEFORE begin_transaction() so the
// REPEATABLE READ view is created only after the book lock is acquired —
// a plain in-txn read here would freeze a pre-lock snapshot and blind
// every later non-locking SELECT to concurrent committed changes.
$lookup = $db->prepare("SELECT libro_id FROM prestiti WHERE id = ? AND stato = 'pendente'");
$lookup->bind_param('i', $loanId);
$lookup->execute();
$lookupRow = $lookup->get_result()->fetch_assoc();
$lookup->close();
if (!$lookupRow) {
$response->getBody()->write(json_encode([
'success' => false,
'message' => __('Prestito non trovato o già processato')
]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
}
$bookId = (int) $lookupRow['libro_id'];

// Start transaction for the atomic terminal transition + availability update.
$db->begin_transaction();

try {
// Canonical lock order: resolve the book without locking, lock `libri`
// first, then lock the pending loan. DataIntegrity locks the same book
// during the availability recalculation; taking the loan first here
// inverted the order used by approval/return and could deadlock.
$lookup = $db->prepare("SELECT libro_id FROM prestiti WHERE id = ? AND stato = 'pendente'");
$lookup->bind_param('i', $loanId);
$lookup->execute();
$lookupRow = $lookup->get_result()->fetch_assoc();
$lookup->close();
if (!$lookupRow) {
$db->rollback();
$response->getBody()->write(json_encode([
'success' => false,
'message' => __('Prestito non trovato o già processato')
]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
}
$bookId = (int) $lookupRow['libro_id'];

// NIENTE filtro deleted_at qui né nella JOIN sottostante (eccezione
// deliberata al soft-delete invariant): rifiutare una richiesta pendente
// deve funzionare ANCHE se il libro è stato soft-eliminato nel frattempo —
Expand Down Expand Up @@ -909,7 +915,11 @@ public function confirmPickup(Request $request, Response $response, mysqli $db):
$copyResult = $copyCheckStmt->get_result()->fetch_assoc();
$copyCheckStmt->close();

$invalidStates = ['perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento'];
// 'prestato' incluso (P2): una copia ancora 'prestato' è fuori con un
// ALTRO prestito aperto (es. predecessore in ritardo non ancora
// rientrato) — confermare il ritiro creerebbe due prestiti attivi
// sulla stessa copia fisica (double-issue).
$invalidStates = ['perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento', 'prestato'];
if (!$copyResult || in_array($copyResult['stato'], $invalidStates, true)) {
// Fail closed: roll back the just-applied 'in_corso' update instead
// of committing a loan over a missing/non-lendable copy (BUG7c/D12).
Expand Down Expand Up @@ -978,19 +988,21 @@ public function cancelPickup(Request $request, Response $response, mysqli $db):
}

try {
$db->begin_transaction();
$today = DateHelper::today();

// ORDINE DI LOCK CANONICO (P3): la riga `libri` per prima, poi `prestiti`.
// Lettura NON bloccante del libro, poi lock nell'ordine libri -> prestiti
// come approveLoan/store/renew (M2, niente lock-order inversion).
// Lettura NON bloccante del libro PRIMA di begin_transaction() (lock-first,
// MVCC): la read view REPEATABLE READ nasce alla prima consistent read in
// transazione — se nascesse qui, prima del lock, le SELECT non bloccanti
// successive non vedrebbero i commit concorrenti avvenuti durante l'attesa
// del lock. Poi lock nell'ordine libri -> prestiti come approveLoan/store/
// renew (M2, niente lock-order inversion).
$bookLookup = $db->prepare("SELECT libro_id FROM prestiti WHERE id = ?");
$bookLookup->bind_param('i', $loanId);
$bookLookup->execute();
$bookRow = $bookLookup->get_result()->fetch_assoc();
$bookLookup->close();
if (!$bookRow) {
$db->rollback();
$response->getBody()->write(json_encode([
'success' => false,
'message' => __('Prestito non trovato o non cancellabile')
Expand All @@ -999,6 +1011,8 @@ public function cancelPickup(Request $request, Response $response, mysqli $db):
}
$libroId = (int) $bookRow['libro_id'];

$db->begin_transaction();

// Lock della riga `libri` SENZA filtro deleted_at: l'annullamento di un
// ritiro deve sempre poter procedere anche su libro soft-deleted (vedi
// LoanRepository::close), altrimenti prestito e copia resterebbero
Expand Down Expand Up @@ -1152,18 +1166,19 @@ public function returnLoan(Request $request, Response $response, mysqli $db): Re
}

try {
$db->begin_transaction();

// ORDINE DI LOCK CANONICO (P3): la riga `libri` per prima, poi `prestiti`.
// Lettura NON bloccante del libro, poi lock nell'ordine libri -> prestiti
// come approveLoan/store/renew (M2, niente lock-order inversion).
// Lettura NON bloccante del libro PRIMA di begin_transaction() (lock-first,
// MVCC): la read view REPEATABLE READ nasce alla prima consistent read in
// transazione — anticiparla al pre-lock renderebbe le SELECT non bloccanti
// successive (promozione coda, capacity gate) cieche ai commit concorrenti
// avvenuti durante l'attesa del lock. Poi lock nell'ordine libri ->
// prestiti come approveLoan/store/renew (M2, niente lock-order inversion).
$bookLookup = $db->prepare("SELECT libro_id FROM prestiti WHERE id = ?");
$bookLookup->bind_param('i', $loanId);
$bookLookup->execute();
$bookRow = $bookLookup->get_result()->fetch_assoc();
$bookLookup->close();
if (!$bookRow) {
$db->rollback();
$response->getBody()->write(json_encode([
'success' => false,
'message' => __('Prestito non trovato o non restituibile')
Expand All @@ -1172,6 +1187,8 @@ public function returnLoan(Request $request, Response $response, mysqli $db): Re
}
$libroId = (int) $bookRow['libro_id'];

$db->begin_transaction();

// Lock della riga `libri` SENZA filtro deleted_at: la RESTITUZIONE deve
// sempre poter procedere anche su libro soft-deleted (vedi il commento in
// LoanRepository::close), altrimenti prestito e copia resterebbero
Expand Down Expand Up @@ -1460,8 +1477,27 @@ public function cancelReservation(Request $request, Response $response, mysqli $
throw new \RuntimeException('Failed to recalculate book availability');
}

// Promote the waitlist: an admin-cancelled reservation frees capacity,
// and every sibling release path (user cancel, admin edit, reject,
// cancelPickup, return) immediately converts the next queued
// reservation — cancelReservation was the only one that left the
// freed capacity idle until the next maintenance run.
$reservationManager = new \App\Controllers\ReservationManager($db);
$reservationManager->setExternalTransaction(true);
for ($promoGuard = 0; $promoGuard < 1000 && $reservationManager->processBookAvailability($libroId); $promoGuard++) {
// keep promoting while freed capacity converts the next queued reservation
}

$db->commit();

// Notifiche accodate durante la transazione esterna (P2): inviale ora
// che il commit è avvenuto, come fa MaintenanceService.
try {
$reservationManager->flushDeferredNotifications();
} catch (\Throwable $flushError) {
\App\Support\SecureLogger::warning("[cancelReservation] Deferred notification flush failed: " . $flushError->getMessage());
}

// Notifica all'utente DOPO il commit (M11): try/catch isolato, un errore
// di invio non deve far fallire l'annullamento già committato.
try {
Expand Down
Loading