130 lines
4.3 KiB
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);
|
|
}
|
|
}
|