CluPilotCloud/app/Console/Commands/PruneExportFolders.php

130 lines
4.3 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\ExportTarget;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
/**
* Empty a handover directory of folders nobody needs any more.
*
* Only where a destination asks for it — keep_days is null by default, because
* a retention rule nobody configured is a deletion nobody expected.
*
* Safe in a way deleting usually is not: the invoice lives in the database as a
* frozen document and its PDF is rendered from that on demand, so anything
* removed here can be produced again and re-exported. The directory is a
* handover point, not the record.
*
* Only ever removes folders whose NAME is a date it can parse and that are old
* enough. Anything else in there — a stray file, a directory somebody made by
* hand, a folder from a different layout — is left alone. A prune that deletes
* what it does not recognise is how an archive loses something nobody was
* watching.
*/
class PruneExportFolders extends Command
{
protected $signature = 'clupilot:prune-exports {--dry-run}';
protected $description = 'Remove dated folders older than a destination keeps them';
public function handle(): int
{
$targets = ExportTarget::query()
->whereNotNull('keep_days')
->where('keep_days', '>', 0)
// Only where this machine can see the files. An SFTP destination is
// somebody else's disk with somebody else's retention on it.
->where('driver', ExportTarget::LOCAL)
->get();
foreach ($targets as $target) {
$this->prune($target);
}
return self::SUCCESS;
}
private function prune(ExportTarget $target): void
{
$root = rtrim($target->path, '/');
if (! is_dir($root)) {
// The mount is gone. Saying nothing and doing nothing is right:
// this is a cleanup, and a cleanup must never be the thing that
// reports a broken mount — nor act on whatever is underneath it.
$this->warn("{$target->name}: path is not there, skipping ({$root})");
return;
}
$cutoff = now()->subDays((int) $target->keep_days)->startOfDay();
$removed = 0;
foreach ((array) scandir($root) as $entry) {
if ($entry === '.' || $entry === '..' || ! is_dir($root.'/'.$entry)) {
continue;
}
$date = $this->dateFrom($entry);
// Not a dated folder: left alone, deliberately. A prune that
// deletes what it does not recognise is how an archive loses
// something nobody was watching.
if ($date === null || $date->gte($cutoff)) {
continue;
}
if ($this->option('dry-run')) {
$this->line("would remove {$root}/{$entry}");
$removed++;
continue;
}
$this->removeTree($root.'/'.$entry);
$removed++;
}
$this->line("{$target->name}: {$removed} folder(s) older than {$target->keep_days} days.");
}
/**
* Only the exact shapes this application writes.
*
* Matched with a pattern BEFORE parsing, not by asking Carbon and catching
* what it does with nonsense: under strict mode createFromFormat throws
* rather than returning false, so a folder somebody made by hand would end
* the whole prune instead of being skipped. The pattern also says plainly
* which two shapes are recognised, which is the thing a reader wants to
* know here.
*/
private function dateFrom(string $name): ?Carbon
{
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $name, $m)) {
return Carbon::createFromFormat('Y-m-d', $name)?->startOfDay();
}
if (preg_match('/^\d{4}$/', $name)) {
return Carbon::createFromFormat('Y-m-d', $name.'-12-31')?->startOfDay();
}
return null;
}
private function removeTree(string $directory): void
{
foreach ((array) scandir($directory) as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $directory.'/'.$entry;
is_dir($path) ? $this->removeTree($path) : @unlink($path);
}
@rmdir($directory);
}
}