Skip to content
Merged
2 changes: 1 addition & 1 deletion app/Controllers/UpdateController.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ public function createBackup(Request $request, Response $response, mysqli $db):
}

$scope = (($data['scope'] ?? 'full') === 'db') ? 'db' : 'full';
$result = (new BackupManager($db, dirname(__DIR__, 2)))->createBackup($scope);
$result = (new BackupManager($db, dirname(__DIR__, 2)))->createBackup($scope, BackupManager::ORIGIN_MANUAL);

if ($result['success']) {
return $this->jsonResponse($response, [
Expand Down
171 changes: 149 additions & 22 deletions app/Support/BackupManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,39 @@ class BackupManager
*/
private const GENERATED_NAME_PATTERN = '/^backup_\d{4}-\d{2}-\d{2}_\d{6}_[0-9a-f]{6}\.zip$/';

/**
* Where a backup came from. Only ORIGIN_AUTO — the copy taken automatically
* before an update — is subject to rotation.
*
* A backup the operator ASKED for is not interchangeable with one the system
* took on its own: it exists because someone decided, at that moment, that
* this state was worth keeping, usually right before doing something risky.
* Letting ten automatic pre-update copies evict it turns a deliberate
* restore point into a rolling window, which is not what the button
* promises. The same holds, more strongly, for the safety copy taken before
* a restore: that one IS the undo.
*
* The origin is encoded in the FILENAME, not only in the manifest, because
* the rotation has to decide from a glob — reading a manifest means opening
* every archive. A non-auto name simply falls outside
* GENERATED_NAME_PATTERN, so it is excluded by the rule that was already
* there for hand-placed archives, with no second rule to keep in sync.
*/
public const ORIGIN_AUTO = 'auto';
public const ORIGIN_MANUAL = 'manual';
public const ORIGIN_SAFETY = 'safety';
public const ORIGIN_UPLOAD = 'upload';

/**
* The pre-0.7.x layout: a directory holding a single database.sql, written
* by an updater that no longer exists. Nothing creates these any more, but
* listBackups() still surfaces them as backups (contents: 'db'), so an
* operator sees them in the same list — and until now nothing pruned them.
* Same discipline as the pattern above: match the EXACT generated shape, so
* a directory someone parked there by hand is never a rotation candidate.
*/
private const LEGACY_DIR_PATTERN = '/^update_\d{4}-\d{2}-\d{2}_\d{6}$/';

/**
* Hard cap for the cumulative DECOMPRESSED size of a restore archive (4 GB).
* Guards against a decompression-bomb ZIP whose compressed form passes
Expand All @@ -75,9 +108,29 @@ public function __construct(mysqli $db, string $rootPath)
* @param string $scope 'full' (DB + files) or 'db' (database only)
* @return array{success: bool, name: string|null, path: string|null, size: int, error: string|null}
*/
public function createBackup(string $scope = 'full'): array
/**
* Build a backup filename carrying its origin.
*
* The automatic shape is left EXACTLY as it was, so every archive already on
* disk keeps being recognised — and keeps being rotated. Anything else gets
* an origin suffix, which puts it outside GENERATED_NAME_PATTERN and thus
* outside the rotation, without a second exclusion rule to maintain.
*/
private static function backupFileName(string $timestamp, string $origin): string
{
$suffix = bin2hex(random_bytes(3));
if ($origin === self::ORIGIN_AUTO) {
return 'backup_' . $timestamp . '_' . $suffix . '.zip';
}
return 'backup_' . $timestamp . '_' . $suffix . '_' . $origin . '.zip';
}

public function createBackup(string $scope = 'full', string $origin = self::ORIGIN_AUTO): array
{
$scope = $scope === 'db' ? 'db' : 'full';
if (!in_array($origin, [self::ORIGIN_AUTO, self::ORIGIN_MANUAL, self::ORIGIN_SAFETY], true)) {
return ['success' => false, 'name' => null, 'path' => null, 'size' => 0, 'error' => __('Origine backup non valida')];
}
$sqlTmp = null;

try {
Expand All @@ -98,7 +151,7 @@ public function createBackup(string $scope = 'full'): array
// A random suffix avoids collisions when two backups land in the
// same second (e.g. a manual backup + the pre-restore safety backup).
$timestamp = date('Y-m-d_His');
$name = 'backup_' . $timestamp . '_' . bin2hex(random_bytes(3)) . '.zip';
$name = self::backupFileName($timestamp, $origin);
$zipPath = $this->backupPath . '/' . $name;

// 1. Dump the database to a temp file.
Expand Down Expand Up @@ -130,6 +183,7 @@ public function createBackup(string $scope = 'full'): array
'version' => $this->getCurrentVersion(),
'created_at' => date('c'),
'scope' => $scope,
'origin' => $origin,
'tables' => $tableCount,
'files' => $fileCount,
'database_sha256' => hash_file('sha256', $sqlTmp) ?: '',
Expand Down Expand Up @@ -242,30 +296,66 @@ private function pruneOldBackups(string $justWritten): void
return;
}

$files = [];
// One pool across BOTH formats, because listBackups() shows them as
// one list sorted by date: rotating them separately would let the
// operator watch a recent entry vanish while an older one survives.
$entries = [];
foreach (glob($this->backupPath . '/backup_*.zip') ?: [] as $file) {
if (!is_file($file) || realpath($file) === realpath($justWritten)) {
continue;
}
if (preg_match(self::GENERATED_NAME_PATTERN, basename($file)) !== 1) {
continue; // hand-placed archive: never a rotation candidate
}
$files[$file] = (int) filemtime($file);
$entries[$file] = (int) filemtime($file);
}
arsort($files);
foreach (glob($this->backupPath . '/update_*', GLOB_ONLYDIR) ?: [] as $dir) {
// A symlink must never be a rotation candidate: deleteDirectory()
// would unlink the link, but a link is not something this class
// wrote and not ours to reclaim.
if (is_link($dir) || !is_dir($dir)) {
continue;
}
if (preg_match(self::LEGACY_DIR_PATTERN, basename($dir)) !== 1) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
continue; // hand-placed directory: never a rotation candidate
}
// A generated legacy backup IS its database.sql — that is the
// whole content of the format. A directory carrying the name but
// not the dump is something else wearing our shape, and this
// rotation deletes recursively: reclaiming only what we can show
// we wrote is worth one stat() per candidate.
if (!is_file($dir . '/database.sql')) {
continue;
}
$entries[$dir] = (int) filemtime($dir);
}
arsort($entries);

// The freshly written file counts against the quota too.
$slots = max(0, $keep - 1);
$stale = array_slice(array_keys($files), $slots);
// Only a newly written automatic backup occupies a retention slot.
$automatic = preg_match(self::GENERATED_NAME_PATTERN, basename($justWritten)) === 1;
$slots = max(0, $keep - ($automatic ? 1 : 0));
$stale = array_slice(array_keys($entries), $slots);
$removed = 0;
foreach ($stale as $file) {
$removedLegacy = 0;
foreach ($stale as $path) {
if (is_dir($path)) {
if ($this->deleteDirectory($path)) {
$removed++;
$removedLegacy++;
}
continue;
}
// nosemgrep: php.lang.security.unlink-use.unlink-use -- glob-matched backup_*.zip under storage/backups, not user input
if (@unlink($file)) {
if (@unlink($path)) {
$removed++;
}
}
if ($removed > 0) {
SecureLogger::info('BackupManager: rotated old backups', ['removed' => $removed, 'kept' => $keep]);
SecureLogger::info('BackupManager: rotated old backups', [
'removed' => $removed,
'legacy_dirs' => $removedLegacy,
'kept' => $keep,
]);
}
} catch (\Throwable $e) {
SecureLogger::warning('BackupManager: backup rotation failed', ['error' => $e->getMessage()]);
Expand All @@ -275,6 +365,27 @@ private function pruneOldBackups(string $justWritten): void
/**
* @return array<int, array{name: string, path: string, size: int, date: string, contents: string, created_at: int}>
*/
/**
* Origin encoded in a filename, for archives whose manifest predates it.
* Unknown or absent suffix means the automatic shape, which is what every
* archive written before this existed actually was.
*/
private static function originFromName(string $name): string
{
if (preg_match('/^backup_\\d{4}-\\d{2}-\\d{2}_\\d{6}_[0-9a-f]{6}_([a-z]+)\\.zip$/', $name, $m) === 1) {
return in_array($m[1], [self::ORIGIN_MANUAL, self::ORIGIN_SAFETY, self::ORIGIN_UPLOAD], true) ? $m[1] : self::ORIGIN_AUTO;
}
return self::ORIGIN_AUTO;
}

/** Human date label, with any origin suffix stripped out of it. */
private static function backupDateLabel(string $name): string
{
$base = pathinfo($name, PATHINFO_FILENAME);
$base = preg_replace('/_(?:' . self::ORIGIN_MANUAL . '|' . self::ORIGIN_SAFETY . '|' . self::ORIGIN_UPLOAD . ')$/', '', $base) ?? $base;
return str_replace(['backup_', '_'], ['', ' '], $base);
}

public function listBackups(): array
{
$backups = [];
Expand All @@ -290,8 +401,17 @@ public function listBackups(): array
'name' => $name,
'path' => $file,
'size' => (int) filesize($file),
'date' => str_replace(['backup_', '_'], ['', ' '], pathinfo($name, PATHINFO_FILENAME)),
'date' => self::backupDateLabel($name),
'contents' => (string) ($manifest['scope'] ?? 'full'),
// The name wins for uploads, and only for them: an uploaded
// archive carries the manifest of ANOTHER installation, which
// would claim 'auto' and hand a restore point to the rotation.
// Everywhere else the manifest is the source and the name the
// fallback — archives written before origins existed carry
// neither, and default to auto, which is what they were.
'origin' => self::originFromName($name) === self::ORIGIN_UPLOAD
? self::ORIGIN_UPLOAD
: (string) ($manifest['origin'] ?? self::originFromName($name)),
'created_at' => (int) filemtime($file),
];
}
Expand All @@ -306,6 +426,7 @@ public function listBackups(): array
'size' => is_file($dbFile) ? (int) filesize($dbFile) : 0,
'date' => str_replace(['update_', '_'], ['', ' '], $name),
'contents' => 'db',
'origin' => self::ORIGIN_AUTO,
'created_at' => (int) filemtime($dir),
];
}
Expand All @@ -325,10 +446,13 @@ public function deleteBackup(string $name): array
}
try {
if (is_dir($target)) {
$this->deleteDirectory($target);
$deleted = $this->deleteDirectory($target);
} else {
// nosemgrep: php.lang.security.unlink-use.unlink-use -- $target validated by resolveBackup() (no traversal, realpath under storage/backups)
@unlink($target);
$deleted = @unlink($target);
}
if (!$deleted) {
return ['success' => false, 'error' => __('Impossibile eliminare il backup. Verifica i permessi e riprova.')];
}
return ['success' => true, 'error' => null];
} catch (\Throwable $e) {
Expand Down Expand Up @@ -408,7 +532,7 @@ public function restoreFromUploadedZip(string $tmpPath, int $size): array
// Same naming scheme as createBackup() so the uploaded archive is listed
// and deletable like any other backup; the random suffix avoids the
// same-second collision a plain timestamp would allow.
$dest = $this->backupPath . '/backup_' . date('Y-m-d_His') . '_' . bin2hex(random_bytes(3)) . '.zip';
$dest = $this->backupPath . '/' . self::backupFileName(date('Y-m-d_His'), self::ORIGIN_UPLOAD);
if (!@rename($tmpPath, $dest) && !@copy($tmpPath, $dest)) {
return ['success' => false, 'safety_backup' => null, 'error' => __('Impossibile salvare il file caricato')];
}
Expand Down Expand Up @@ -525,7 +649,7 @@ private function doRestoreZip(string $zipPath): array
try {
// 1. Safety backup of the current state (always full) — the rollback
// path, since MySQL DDL can't run inside a transaction.
$safety = $this->createBackup('full');
$safety = $this->createBackup('full', self::ORIGIN_SAFETY);
if (!$safety['success']) {
throw new \RuntimeException(__('Impossibile creare il backup di sicurezza pre-ripristino') . ': ' . (string) $safety['error']);
}
Expand Down Expand Up @@ -1420,22 +1544,21 @@ private function readManifestFromZip(string $zipPath): array
return is_array($decoded) ? $decoded : [];
}

private function deleteDirectory(string $dir): void
private function deleteDirectory(string $dir): bool
{
// A symlinked root must not be followed either — unlink the link
// itself, never recurse into its target (symmetric with the per-child
// is_link guard below; is_dir() returns true through a dir symlink). (#167 review)
if (is_link($dir)) {
// nosemgrep: php.lang.security.unlink-use.unlink-use -- removes the symlink, not its target
@unlink($dir);
return;
return @unlink($dir);
}
if (!is_dir($dir)) {
return;
return true; // already gone: the postcondition holds
}
$files = @scandir($dir);
if ($files === false) {
return;
return false;
}
foreach (array_diff($files, ['.', '..']) as $file) {
$path = $dir . '/' . $file;
Expand All @@ -1450,7 +1573,11 @@ private function deleteDirectory(string $dir): void
@unlink($path);
}
}
@rmdir($dir);
// The return value is what the rotation counts on. Re-checking the path
// afterwards would be the obvious alternative, but static analysis has
// already narrowed it to "a directory" and cannot see a filesystem side
// effect, so the helper reports its own outcome instead.
return @rmdir($dir);
}

private function getCurrentVersion(): string
Expand Down
2 changes: 2 additions & 0 deletions locale/da_DK.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
{
"Origine backup non valida": "Ugyldig sikkerhedskopikilde",
"Impossibile eliminare il backup. Verifica i permessi e riprova.": "Sikkerhedskopien kunne ikke slettes. Kontrollér rettighederne, og prøv igen.",
"Importa UNIMARC (MARCXchange)": "Importér UNIMARC (MARCXchange)",
"Incolla un record UNIMARC/MARCXchange, oppure carica un file .xml, per precompilare il form dal record esportato.": "Indsæt en UNIMARC/MARCXchange-post, eller upload en .xml-fil, for at udfylde formularen ud fra den eksporterede post.",
"Incolla o carica un record UNIMARC.": "Indsæt eller upload en UNIMARC-post.",
Expand Down
2 changes: 2 additions & 0 deletions locale/de_DE.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
{
"Origine backup non valida": "Ungültiger Sicherungsursprung",
"Impossibile eliminare il backup. Verifica i permessi e riprova.": "Die Sicherung kann nicht gelöscht werden. Prüfen Sie die Berechtigungen und versuchen Sie es erneut.",
"Importa UNIMARC (MARCXchange)": "UNIMARC importieren (MARCXchange)",
"Incolla un record UNIMARC/MARCXchange, oppure carica un file .xml, per precompilare il form dal record esportato.": "Fügen Sie einen UNIMARC/MARCXchange-Datensatz ein oder laden Sie eine .xml-Datei hoch, um das Formular aus dem exportierten Datensatz vorauszufüllen.",
"Incolla o carica un record UNIMARC.": "Fügen Sie einen UNIMARC-Datensatz ein oder laden Sie ihn hoch.",
Expand Down
2 changes: 2 additions & 0 deletions locale/en_US.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
{
"Origine backup non valida": "Invalid backup origin",
"Impossibile eliminare il backup. Verifica i permessi e riprova.": "Unable to delete the backup. Check permissions and try again.",
"Importa UNIMARC (MARCXchange)": "Import UNIMARC (MARCXchange)",
"Incolla un record UNIMARC/MARCXchange, oppure carica un file .xml, per precompilare il form dal record esportato.": "Paste a UNIMARC/MARCXchange record, or upload an .xml file, to pre-fill the form from the exported record.",
"Incolla o carica un record UNIMARC.": "Paste or upload a UNIMARC record.",
Expand Down
2 changes: 2 additions & 0 deletions locale/fr_FR.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
{
"Origine backup non valida": "Origine de sauvegarde non valide",
"Impossibile eliminare il backup. Verifica i permessi e riprova.": "Impossible de supprimer la sauvegarde. Vérifiez les permissions et réessayez.",
"Importa UNIMARC (MARCXchange)": "Importer UNIMARC (MARCXchange)",
"Incolla un record UNIMARC/MARCXchange, oppure carica un file .xml, per precompilare il form dal record esportato.": "Collez une notice UNIMARC/MARCXchange, ou téléversez un fichier .xml, pour préremplir le formulaire à partir de la notice exportée.",
"Incolla o carica un record UNIMARC.": "Collez ou téléversez une notice UNIMARC.",
Expand Down
2 changes: 2 additions & 0 deletions locale/it_IT.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
{
"Origine backup non valida": "Origine backup non valida",
"Impossibile eliminare il backup. Verifica i permessi e riprova.": "Impossibile eliminare il backup. Verifica i permessi e riprova.",
"Importa UNIMARC (MARCXchange)": "Importa UNIMARC (MARCXchange)",
"Incolla un record UNIMARC/MARCXchange, oppure carica un file .xml, per precompilare il form dal record esportato.": "Incolla un record UNIMARC/MARCXchange, oppure carica un file .xml, per precompilare il form dal record esportato.",
"Incolla o carica un record UNIMARC.": "Incolla o carica un record UNIMARC.",
Expand Down
13 changes: 11 additions & 2 deletions storage/plugins/api-book-scraper/ApiBookScraperPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,18 @@ private function registerHooks(): void
return;
}

// Se il plugin non è abilitato, non registrare gli hooks
// Se il plugin non è abilitato, non registrare gli hooks.
//
// DEBUG, not warning: a plugin that is present but not configured is the
// normal state of every plugin the operator has not set up, and this path
// is reached on a schedule. On one production install it produced 8.217 of
// the 15.607 lines in app.log over eight months — more than half the file,
// for a condition that is not a problem. A warning level that fires
// continuously trains the reader to skip warnings, which is how the real
// ones get missed. The genuinely abnormal case above (missing DB or plugin
// ID) keeps its warning.
if (!$this->enabled || empty($this->apiEndpoint) || empty($this->apiKey)) {
\App\Support\SecureLogger::warning('[ApiBookScraper] Plugin not enabled or missing configuration');
\App\Support\SecureLogger::debug('[ApiBookScraper] Plugin not enabled or missing configuration: hooks not registered');
return;
}

Expand Down
Loading