Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
292f158
feat: genre merge, rearrange, book list filters, plugin hardening
fabiodalez-dev Feb 23, 2026
dfc8b39
fix: apply CodeRabbit review — security, performance, correctness
fabiodalez-dev Feb 23, 2026
c3f29c4
fix: CodeRabbit round 2 — clearable URL filters, stmt leaks, cascade …
fabiodalez-dev Feb 23, 2026
d9cec8f
fix: CodeRabbit round 3 — cycle detection, merge safety, filter cleanup
fabiodalez-dev Feb 23, 2026
01f2611
fix: CodeRabbit round 5 — dangling FK, update checks, merge safety
fabiodalez-dev Feb 23, 2026
26f94af
fix: CodeRabbit round 6 — ancestor guard, delete check, parent dropdo…
fabiodalez-dev Feb 23, 2026
3ce62af
fix: transaction safety, cycle depth, self-parent guard, error exposure
fabiodalez-dev Feb 23, 2026
db32151
fix: CodeRabbit round 8 — null parent, ancestor guards, defensive checks
fabiodalez-dev Feb 24, 2026
d082157
fix: CodeRabbit round 9 — null guards, merge reparent, dead htaccess
fabiodalez-dev Feb 24, 2026
8cfea8b
fix: security hardening — XSS, info disclosure, soft-delete, MIME val…
fabiodalez-dev Feb 24, 2026
d34f4d9
fix: CodeRabbit round 11 — token logging, SQL comments, MIME, test creds
fabiodalez-dev Feb 24, 2026
7ed5939
test: add comprehensive loan/reservation lifecycle E2E tests
fabiodalez-dev Feb 24, 2026
a90147a
fix: CodeRabbit round 12 — test creds, null guards, dead code
fabiodalez-dev Feb 24, 2026
bb57519
test: add comprehensive admin features E2E test suite
fabiodalez-dev Feb 24, 2026
5e8d066
test: add 20 extra E2E tests for untested features
fabiodalez-dev Feb 24, 2026
fda838d
chore: bump version to 0.4.9.1
fabiodalez-dev Feb 24, 2026
3371d9e
chore: production autoloader (no dev deps)
fabiodalez-dev Feb 24, 2026
ecf3332
fix: CodeRabbit round 13 — @unlink, test creds, bulk delete, .htaccess
fabiodalez-dev Feb 24, 2026
98b6f74
chore: restore vendor/composer with dev autoloader
fabiodalez-dev Feb 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
16 changes: 16 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,19 @@

# Exclude from release archives (git archive)
public/installer/assets export-ignore
tests/ export-ignore
test/ export-ignore
.github/ export-ignore
docs/ export-ignore
.distignore export-ignore
frontend/ export-ignore
node_modules/ export-ignore
*.log export-ignore
.DS_Store export-ignore
.claude/ export-ignore
.gemini/ export-ignore
.qoder/ export-ignore
.cursor/ export-ignore
.vscode/ export-ignore
.idea/ export-ignore
internal/ export-ignore
23 changes: 15 additions & 8 deletions app/Controllers/Admin/LanguagesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use App\Support\ConfigStore;
use App\Support\HtmlHelper;
use App\Support\I18n;
use App\Support\SecureLogger;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;

Expand Down Expand Up @@ -131,7 +132,8 @@ public function store(Request $request, Response $response, \mysqli $db, array $
->withHeader('Location', '/admin/languages')
->withStatus(302);
} catch (\Throwable $e) {
$_SESSION['flash_error'] = __("Errore nella creazione:") . " " . $e->getMessage();
SecureLogger::error('LanguagesController::store error: ' . $e->getMessage());
$_SESSION['flash_error'] = __("Errore nella creazione della lingua.");
return $response
->withHeader('Location', '/admin/languages/create')
->withStatus(302);
Expand Down Expand Up @@ -248,7 +250,8 @@ public function update(Request $request, Response $response, \mysqli $db, array
->withHeader('Location', '/admin/languages')
->withStatus(302);
} catch (\Throwable $e) {
$_SESSION['flash_error'] = __("Errore nell'aggiornamento:") . " " . $e->getMessage();
SecureLogger::error('LanguagesController::update error: ' . $e->getMessage());
$_SESSION['flash_error'] = __("Errore nell'aggiornamento della lingua.");
return $response
->withHeader('Location', '/admin/languages/' . urlencode($code) . '/edit')
->withStatus(302);
Expand Down Expand Up @@ -286,7 +289,8 @@ public function delete(Request $request, Response $response, \mysqli $db, array

$_SESSION['flash_success'] = __("Lingua eliminata con successo");
} catch (\Throwable $e) {
$_SESSION['flash_error'] = __("Errore nell'eliminazione:") . " " . $e->getMessage();
SecureLogger::error('LanguagesController::delete error: ' . $e->getMessage());
$_SESSION['flash_error'] = __("Errore nell'eliminazione della lingua.");
}

return $response
Expand Down Expand Up @@ -318,7 +322,8 @@ public function toggleActive(Request $request, Response $response, \mysqli $db,
$statusText = $newStatus ? __("attivata") : __("disattivata");
$_SESSION['flash_success'] = __("Lingua") . " " . $statusText . " " . __("con successo");
} catch (\Throwable $e) {
$_SESSION['flash_error'] = __("Errore nell'operazione:") . " " . $e->getMessage();
SecureLogger::error('LanguagesController::toggleActive error: ' . $e->getMessage());
$_SESSION['flash_error'] = __("Errore nell'operazione.");
}

return $response
Expand Down Expand Up @@ -349,7 +354,8 @@ public function setDefault(Request $request, Response $response, \mysqli $db, ar
$this->synchronizeGlobalLocale($db, $code);
$_SESSION['flash_success'] = __("Lingua predefinita impostata con successo");
} catch (\Throwable $e) {
$_SESSION['flash_error'] = __("Errore nell'operazione:") . " " . $e->getMessage();
SecureLogger::error('LanguagesController::setDefault error: ' . $e->getMessage());
$_SESSION['flash_error'] = __("Errore nell'operazione.");
}

return $response
Expand Down Expand Up @@ -574,13 +580,13 @@ private function synchronizeGlobalLocale(\mysqli $db, string $code): void
$settingsRepo = new SettingsRepository($db);
$settingsRepo->set('app', 'locale', $normalized);
} catch (\Throwable $e) {
error_log('[LanguagesController] Unable to save locale in system_settings: ' . $e->getMessage());
SecureLogger::error('LanguagesController: Unable to save locale in system_settings: ' . $e->getMessage());
}

try {
ConfigStore::set('app.locale', $normalized);
} catch (\Throwable $e) {
error_log('[LanguagesController] Unable to save locale in settings store: ' . $e->getMessage());
SecureLogger::error('LanguagesController: Unable to save locale in settings store: ' . $e->getMessage());
}

$this->updateEnvLocale($normalized);
Expand Down Expand Up @@ -760,7 +766,8 @@ public function updateRoutes(Request $request, Response $response, \mysqli $db,
->withHeader('Location', '/admin/languages/' . urlencode($code) . '/edit-routes')
->withStatus(302);
} catch (\Throwable $e) {
$_SESSION['flash_error'] = __("Errore nell'aggiornamento:") . " " . $e->getMessage();
SecureLogger::error('LanguagesController::updateRoutes error: ' . $e->getMessage());
$_SESSION['flash_error'] = __("Errore nell'aggiornamento delle route.");
return $response
->withHeader('Location', '/admin/languages/' . urlencode($code) . '/edit-routes')
->withStatus(302);
Expand Down
23 changes: 13 additions & 10 deletions app/Controllers/CmsController.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);

namespace App\Controllers;

Expand Down Expand Up @@ -198,7 +199,7 @@ public function updateHome(Request $request, Response $response, \mysqli $db, ar
// SECURITY: Secure path handling to prevent directory traversal
$baseDir = realpath(__DIR__ . '/../../public/uploads');
if ($baseDir === false) {
error_log("Upload base directory not found");
\App\Support\SecureLogger::error('CmsController: Upload base directory not found');
$errors[] = 'Errore di configurazione directory upload.';
} else {
$targetDir = $baseDir . '/assets';
Expand All @@ -212,8 +213,8 @@ public function updateHome(Request $request, Response $response, \mysqli $db, ar
$randomSuffix = '';
try {
$randomSuffix = bin2hex(random_bytes(8));
} catch (\Exception $e) {
error_log("CRITICAL: random_bytes() failed - system entropy exhausted");
} catch (\Throwable $e) {
\App\Support\SecureLogger::error('CmsController: random_bytes() failed: ' . $e->getMessage());
$errors[] = 'Errore di sistema. Riprova più tardi.';
}

Expand All @@ -226,16 +227,16 @@ public function updateHome(Request $request, Response $response, \mysqli $db, ar
// SECURITY: Verify final path is within allowed directory
$realUploadPath = realpath(dirname($uploadPath));
if ($realUploadPath === false || strpos($realUploadPath, $baseDir) !== 0) {
error_log("Path traversal attempt detected");
\App\Support\SecureLogger::error('CmsController: Path traversal attempt detected');
$errors[] = 'Percorso file non valido.';
} else {
try {
$uploadedFile->moveTo($uploadPath);
// SECURITY: Set secure file permissions
@chmod($uploadPath, 0644);
$bgImagePath = '/assets/' . $newFilename;
} catch (\Exception $e) {
error_log("Image upload error: " . $e->getMessage());
} catch (\Throwable $e) {
\App\Support\SecureLogger::error('CmsController: Image upload error: ' . $e->getMessage());
$errors[] = 'Errore durante l\'upload dell\'immagine. Riprova.';
}
}
Expand Down Expand Up @@ -529,9 +530,10 @@ public function reorderHomeSections(Request $request, Response $response, \mysql
$response->getBody()->write(json_encode(['success' => true, 'message' => 'Order updated successfully']));
return $response->withHeader('Content-Type', 'application/json');

} catch (\Exception $e) {
} catch (\Throwable $e) {
$db->rollback();
$response->getBody()->write(json_encode(['success' => false, 'message' => $e->getMessage()]));
\App\Support\SecureLogger::error('CmsController::reorderHomeSections error: ' . $e->getMessage());
$response->getBody()->write(json_encode(['success' => false, 'message' => __('Errore durante il riordinamento delle sezioni.')]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(500);
}
}
Expand Down Expand Up @@ -568,8 +570,9 @@ public function toggleSectionVisibility(Request $request, Response $response, \m
$response->getBody()->write(json_encode(['success' => false, 'message' => 'Update failed']));
return $response->withHeader('Content-Type', 'application/json')->withStatus(500);
}
} catch (\Exception $e) {
$response->getBody()->write(json_encode(['success' => false, 'message' => $e->getMessage()]));
} catch (\Throwable $e) {
\App\Support\SecureLogger::error('CmsController::toggleSectionVisibility error: ' . $e->getMessage());
$response->getBody()->write(json_encode(['success' => false, 'message' => __('Errore durante l\'aggiornamento della visibilità.')]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(500);
}
}
Expand Down
4 changes: 3 additions & 1 deletion app/Controllers/CollocazioneController.php
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,8 @@ public function getLibri(Request $request, Response $response, mysqli $db): Resp

$stmt = $db->prepare($sql);
if ($stmt === false) {
$response->getBody()->write(json_encode(['error' => __('Errore nella query.'), 'detail' => $db->error], JSON_UNESCAPED_UNICODE));
\App\Support\SecureLogger::error('CollocazioneController::getLibri query failed: ' . $db->error);
$response->getBody()->write(json_encode(['error' => __('Errore nella query.')], JSON_UNESCAPED_UNICODE));
return $response->withStatus(500)->withHeader('Content-Type', 'application/json');
}

Expand Down Expand Up @@ -362,6 +363,7 @@ public function exportCSV(Request $request, Response $response, mysqli $db): Res

$stmt = $db->prepare($sql);
if ($stmt === false) {
\App\Support\SecureLogger::error('CollocazioneController::exportCSV query failed: ' . $db->error);
$response->getBody()->write(__('Errore nella query.'));
return $response->withStatus(500);
}
Expand Down
94 changes: 84 additions & 10 deletions app/Controllers/GeneriApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public function search(Request $request, Response $response, \mysqli $db): Respo
}
}

$response->getBody()->write(json_encode($results, JSON_UNESCAPED_UNICODE));
$response->getBody()->write(json_encode($results, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE));
return $response->withHeader('Content-Type', 'application/json');
}

Expand All @@ -64,7 +64,7 @@ public function create(Request $request, Response $response, \mysqli $db): Respo
$parent_id = !empty($data['parent_id']) ? (int) $data['parent_id'] : null;

if (empty($nome)) {
$response->getBody()->write(json_encode(['error' => __('Il nome del genere è obbligatorio.')]));
$response->getBody()->write(json_encode(['error' => __('Il nome del genere è obbligatorio.')], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}

Expand All @@ -79,7 +79,7 @@ public function create(Request $request, Response $response, \mysqli $db): Respo
'id' => (int) $existing['id'],
'nome' => $nome,
'exists' => true
]));
], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE));
return $response->withHeader('Content-Type', 'application/json');
}

Expand All @@ -95,14 +95,89 @@ public function create(Request $request, Response $response, \mysqli $db): Respo
'nome' => $nome,
'parent_id' => $parent_id,
'created' => true
]));
], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE));
return $response->withStatus(201)->withHeader('Content-Type', 'application/json');
}

$response->getBody()->write(json_encode(['error' => __('Errore nella creazione del genere.')]));
$response->getBody()->write(json_encode(['error' => __('Errore nella creazione del genere.')], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE));
return $response->withStatus(500)->withHeader('Content-Type', 'application/json');
}

public function update(Request $request, Response $response, \mysqli $db, int $id): Response
{
// CSRF validated by CsrfMiddleware
$data = $request->getParsedBody();
if (empty($data)) {
$data = json_decode((string)$request->getBody(), true) ?? [];
}

$nome = trim((string)($data['nome'] ?? ''));
if (empty($nome)) {
$response->getBody()->write(json_encode(['error' => __('Il nome del genere è obbligatorio.')], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}

$repo = new \App\Models\GenereRepository($db);
$genere = $repo->getById($id);
if (!$genere) {
$response->getBody()->write(json_encode(['error' => __('Genere non trovato.')], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE));
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
}

try {
$updateData = ['nome' => $nome];
if (isset($data['parent_id'])) {
$newParent = !empty($data['parent_id']) ? (int)$data['parent_id'] : null;
if ($newParent === $id) {
$response->getBody()->write(json_encode(['error' => __('Un genere non può essere genitore di sé stesso.')], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
// Cycle detection: walk ancestor chain to prevent A→B→A
if ($newParent !== null) {
$ancestorId = $newParent;
$depth = 100;
$aStmt = $db->prepare('SELECT parent_id FROM generi WHERE id = ?');
if (!$aStmt) {
\App\Support\SecureLogger::error('GeneriApiController::update prepare() failed');
$response->getBody()->write(json_encode(['error' => __('Errore interno.')], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE));
return $response->withStatus(500)->withHeader('Content-Type', 'application/json');
}
while ($ancestorId > 0 && $depth-- > 0) {
if ($ancestorId === $id) {
$aStmt->close();
$response->getBody()->write(json_encode(['error' => __('Impossibile: si creerebbe un ciclo.')], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
$aStmt->bind_param('i', $ancestorId);
$aStmt->execute();
$aRow = $aStmt->get_result()->fetch_assoc();
$ancestorId = $aRow ? (int)($aRow['parent_id'] ?? 0) : 0;
}
$aStmt->close();
}
$updateData['parent_id'] = $newParent;
}

if (!$repo->update($id, $updateData)) {
throw new \RuntimeException('update() returned false');
}
$response->getBody()->write(json_encode([
'id' => $id,
'nome' => $nome,
'updated' => true
], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE));
return $response->withHeader('Content-Type', 'application/json');
} catch (\Throwable $e) {
if ($e instanceof \InvalidArgumentException) {
$response->getBody()->write(json_encode(['error' => $e->getMessage()], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
\App\Support\SecureLogger::error('GeneriApiController::update error', ['id' => $id, 'message' => $e->getMessage()]);
$response->getBody()->write(json_encode(['error' => __('Errore interno durante l\'aggiornamento.')], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE));
return $response->withStatus(500)->withHeader('Content-Type', 'application/json');
}
}

public function listGeneri(Request $request, Response $response, \mysqli $db): Response
{
$limit = min(100, (int) ($request->getQueryParams()['limit'] ?? 50));
Expand Down Expand Up @@ -141,7 +216,7 @@ public function listGeneri(Request $request, Response $response, \mysqli $db): R
];
}

$response->getBody()->write(json_encode($results, JSON_UNESCAPED_UNICODE));
$response->getBody()->write(json_encode($results, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE));
return $response->withHeader('Content-Type', 'application/json');
}

Expand All @@ -150,7 +225,7 @@ public function getSottogeneri(Request $request, Response $response, \mysqli $db
$parent_id = (int) ($request->getQueryParams()['parent_id'] ?? 0);

if ($parent_id <= 0) {
$response->getBody()->write(json_encode([]));
$response->getBody()->write(json_encode([], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE));
return $response->withHeader('Content-Type', 'application/json');
}

Expand All @@ -173,8 +248,7 @@ public function getSottogeneri(Request $request, Response $response, \mysqli $db
];
}

$response->getBody()->write(json_encode($results, JSON_UNESCAPED_UNICODE));
$response->getBody()->write(json_encode($results, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE));
return $response->withHeader('Content-Type', 'application/json');
}
}
?>
}
Loading