3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-18 11:15:42 +00:00

Purge: More refactors from from #19333

This commit is contained in:
snipe
2026-07-21 16:44:33 +01:00
parent 17b0e43961
commit 3d48220b33

View File

@ -2,13 +2,28 @@
namespace App\Console\Commands;
use App\Models\Accessory;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\Category;
use App\Models\CheckoutAcceptance;
use App\Models\Company;
use App\Models\Component;
use App\Models\Consumable;
use App\Models\Department;
use App\Models\License;
use App\Models\Location;
use App\Models\Maintenance;
use App\Models\Manufacturer;
use App\Models\Supplier;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use ReflectionClass;
use function Laravel\Prompts\confirm;
@ -27,32 +42,84 @@ class Purge extends Command
{--force=false : Skip the confirmation prompt (accepts "true").}
{--dry-run : Report what would be purged without deleting anything.}';
protected $description = 'Purge all soft-deleted records in the database. Walks every model that uses the SoftDeletes trait, DELETEs the trashed rows, and cleans up their polymorphic action_log children. No undo.';
protected $description = 'Purge all soft-deleted records in the database. Walks every model that uses the SoftDeletes trait, DELETEs the trashed rows, cleans up their polymorphic action_log children, and removes their uploaded files and image assets from disk. No undo.';
/**
* Users are excluded even when soft-deleted if show_in_list='0'. System
* users (LDAP-sync placeholders, etc.) set this to '0' and shouldn't be
* garbage-collected here.
* Non-soft-deletable child tables that get nuked when their parent
* is purged, even if the child itself was not soft-deleted. Auto-
* discovery finds all soft-deletable models, but a trashed License
* with live LicenseSeats or a trashed Asset with live Maintenances
* would leave orphans behind if we only nuked soft-deleted rows.
* Keyed by parent model class, value maps `child_table => foreign_key`.
*/
private const PARENT_QUERY_FILTERS = [
User::class => [['column' => 'show_in_list', 'op' => '!=', 'value' => '0']],
private const CHILD_TABLES = [
Asset::class => ['maintenances' => 'asset_id'],
License::class => ['license_seats' => 'license_id'],
];
/**
* Non-soft-deletable FK children that should be nuked when their parent
* is purged, even if the child itself was not soft-deleted. Auto-
* discovery finds all soft-deletable models, but a trashed License with
* live LicenseSeats or a trashed Asset with live Maintenances would
* leave orphans behind if we only nuked soft-deleted rows. This map
* covers the parent→child relations where the parent "owns" the child
* outright and orphaning it makes no sense.
* Image/avatar files stored on the public disk, keyed by parent
* model. Value is `column_name => public-disk subpath`. When the
* parent is purged, the file at `{subpath}/{column_value}` gets
* removed from the `public` disk.
*
* Only lives here in the purge (not in each controller's destroy
* method) so that soft-deleting a row does NOT delete the image.
* That way a soft-deleted row can be restored with its image intact.
* The image is only permanently removed when the row is permanently
* removed (via this purge).
*/
private const FK_CHILDREN = [
Asset::class => [
['table' => 'maintenances', 'foreign_key' => 'asset_id'],
],
License::class => [
['table' => 'license_seats', 'foreign_key' => 'license_id'],
private const IMAGE_FILES = [
User::class => ['avatar' => 'avatars'],
Asset::class => ['image' => 'assets'],
AssetModel::class => ['image' => 'models'],
Accessory::class => ['image' => 'accessories'],
Category::class => ['image' => 'categories'],
Company::class => ['image' => 'companies'],
Component::class => ['image' => 'components'],
Consumable::class => ['image' => 'consumables'],
Department::class => ['image' => 'departments'],
Location::class => ['image' => 'locations'],
Manufacturer::class => ['image' => 'manufacturers'],
Supplier::class => ['image' => 'suppliers'],
];
/**
* "Files" tab attachment roots under `private_uploads/`, keyed by
* parent model. These are the contracts, receipts, photos, etc.
* tracked in `action_logs` with `action_type = 'uploaded'`. On
* purge, all such files for the trashed parent get unlinked from
* the default disk.
*
* As with IMAGE_FILES, this is deliberately not done at soft-delete
* time so restoring a soft-deleted row brings the files back with it.
*/
private const UPLOAD_ROOTS = [
Accessory::class => 'private_uploads/accessories',
Asset::class => 'private_uploads/assets',
AssetModel::class => 'private_uploads/models',
Company::class => 'private_uploads/companies',
Component::class => 'private_uploads/components',
Consumable::class => 'private_uploads/consumables',
Department::class => 'private_uploads/departments',
License::class => 'private_uploads/licenses',
Location::class => 'private_uploads/locations',
Maintenance::class => 'private_uploads/maintenances',
Supplier::class => 'private_uploads/suppliers',
User::class => 'private_uploads/users',
];
/**
* Additional file-holding columns on the parent row itself (not on
* action_logs), keyed by model. `CheckoutAcceptance` stores the
* signature filename and the rendered EULA PDF filename inline;
* both need to be unlinked when the acceptance row is purged.
* Value is `column_name => private-disk subpath`.
*/
private const PRIVATE_FILE_COLUMNS = [
CheckoutAcceptance::class => [
'signature_filename' => 'private_uploads/signatures',
'stored_eula_file' => 'private_uploads/eula-pdfs',
],
];
@ -112,7 +179,7 @@ class Purge extends Command
*
* Dedupe by table so single-table inheritance / subclassed models
* (e.g. SCIMUser extends User, same `users` table) don't get processed
* twice — the first pass would run without the subclass-specific
* twice. The first pass would run without the subclass-specific
* filters (`show_in_list != 0` for users) and delete the rows the
* parent's filter was supposed to preserve. Prefer the base class:
* the more-derived class is skipped if a parent for its table was
@ -158,15 +225,10 @@ class Purge extends Command
}
/**
* Nuke one model's trashed rows: pluck the trashed ids, wipe their
* polymorphic action_log children (via `item_type`/`item_id` and
* `target_type`/`target_id`), then bulk-delete the parents by their
* `deleted_at` index.
*
* Children go per-row DELETE against the composite index on
* action_logs — bulk WHERE IN and JOIN DELETE both benchmarked ~3x
* slower on MariaDB (InnoDB commits small autocommit transactions
* faster than one long one against the composite index).
* Nuke one model's trashed rows: pluck the trashed ids, delete
* on-disk files (images and uploaded files) associated with those
* rows, wipe polymorphic action_log children, wipe FK child tables,
* then bulk-delete the parents by their `deleted_at` index.
*
* @return array<int, array{0: string, 1: int}>
*/
@ -177,8 +239,13 @@ class Purge extends Command
$label = class_basename($modelClass);
$parentQuery = DB::table($table)->whereNotNull('deleted_at');
foreach (self::PARENT_QUERY_FILTERS[$modelClass] ?? [] as $f) {
$parentQuery->where($f['column'], $f['op'], $f['value']);
// show_in_list=0 excludes a user from checkout-target dropdowns
// in the UI. Preserved by the purge (matches the pre-refactor
// behavior) so users with this flag stick around even when
// soft-deleted.
if ($modelClass === User::class) {
$parentQuery->where('show_in_list', '!=', '0');
}
$ids = (clone $parentQuery)->pluck('id');
@ -186,7 +253,15 @@ class Purge extends Command
return [];
}
$rows = [];
// File cleanup runs before the DB deletes so we can still read
// the image/avatar column off the parent row and correlate
// action_logs to a still-existing parent. Skipped during dry-run
// so `--dry-run` truly writes nothing.
if (! $dryRun) {
$this->deleteImageFiles($modelClass, $table, $ids);
$this->deleteActionLogFiles($modelClass, $ids);
$this->deletePrivateFileColumns($modelClass, $table, $ids);
}
// Polymorphic action_log cleanup. Every model referenced by
// action_logs uses one of two column pairs. Users use target_*,
@ -194,46 +269,41 @@ class Purge extends Command
$itemLogs = 0;
$targetLogs = 0;
foreach ($ids as $id) {
$itemQuery = DB::table('action_logs')
->where('item_type', $modelClass)
->where('item_id', $id);
$targetQuery = DB::table('action_logs')
->where('target_type', $modelClass)
->where('target_id', $id);
if ($dryRun) {
$itemLogs += DB::table('action_logs')
->where('item_type', $modelClass)
->where('item_id', $id)
->count();
$targetLogs += DB::table('action_logs')
->where('target_type', $modelClass)
->where('target_id', $id)
->count();
$itemLogs += $itemQuery->count();
$targetLogs += $targetQuery->count();
} else {
$itemLogs += DB::table('action_logs')
->where('item_type', $modelClass)
->where('item_id', $id)
->delete();
$targetLogs += DB::table('action_logs')
->where('target_type', $modelClass)
->where('target_id', $id)
->delete();
$itemLogs += $itemQuery->delete();
$targetLogs += $targetQuery->delete();
}
}
// FK-child cleanup: rows in other tables that belong to a trashed
// parent by a plain foreign key (see FK_CHILDREN docblock). These
// are nuked whole rather than only-trashed because a live
// LicenseSeat pointing at a purged License is an orphan by
// definition.
$fkChildCounts = [];
foreach (self::FK_CHILDREN[$modelClass] ?? [] as $child) {
// Child-table cleanup: rows in other tables that belong to a
// trashed parent by a plain foreign key (see CHILD_TABLES
// docblock). Nuked whole rather than only-trashed because a
// live LicenseSeat pointing at a purged License is an orphan
// by definition.
$childCounts = [];
foreach (self::CHILD_TABLES[$modelClass] ?? [] as $childTable => $foreignKey) {
$count = 0;
foreach ($ids as $id) {
$q = DB::table($child['table'])->where($child['foreign_key'], $id);
$q = DB::table($childTable)->where($foreignKey, $id);
$count += $dryRun ? $q->count() : $q->delete();
}
if ($count > 0) {
$fkChildCounts[$child['table']] = $count;
$childCounts[$childTable] = $count;
}
}
$parentCount = $dryRun ? $ids->count() : $parentQuery->delete();
$rows = [];
$rows[] = [$label, $parentCount];
if ($itemLogs > 0) {
$rows[] = [$label.' action_logs (item)', $itemLogs];
@ -241,10 +311,149 @@ class Purge extends Command
if ($targetLogs > 0) {
$rows[] = [$label.' action_logs (target)', $targetLogs];
}
foreach ($fkChildCounts as $table => $count) {
$rows[] = [$table, $count];
foreach ($childCounts as $childTable => $count) {
$rows[] = [$childTable, $count];
}
return $rows;
}
/**
* Delete image/avatar files stored on the public disk for the
* trashed rows. Reads the filename off each trashed parent row,
* then unlinks `{subpath}/{filename}` from the public disk.
*/
private function deleteImageFiles(string $modelClass, string $table, Collection $ids): void
{
foreach (self::IMAGE_FILES[$modelClass] ?? [] as $column => $subpath) {
$filenames = DB::table($table)
->whereIn('id', $ids)
->pluck($column)
->filter()
->unique();
foreach ($filenames as $filename) {
try {
$key = trim($subpath, '/').'/'.basename($filename);
if (Storage::disk('public')->exists($key)) {
Storage::disk('public')->delete($key);
}
} catch (\Exception $e) {
Log::info(sprintf(
'snipeit:purge - error deleting %s file %s for %s: %s',
$column, $filename, $modelClass, $e->getMessage()
));
}
}
}
}
/**
* Delete every file referenced by action_logs whose parent row is
* about to be purged. Covers four categories of file, keyed by
* `action_type` on the log:
*
* - `uploaded` → `private_uploads/{type}/` (Files tab attachments)
* - `audit` → `private_uploads/audits/`
* - `accepted` → `private_uploads/eula-pdfs/`
* - `declined` → `private_uploads/eula-pdfs/`
*
* Plus, independent of action_type, the `accept_signature` column
* can point at a signature file under `private_uploads/signatures/`.
*
* Match rows via BOTH the item_* and target_* column pairs. When
* purging a user, we want signatures/EULAs stored under target_id
* (the accepting user) even though the checkoutable item's
* item_type points at Asset/License/etc.
*
* Failure to unlink is logged but not fatal.
*/
private function deleteActionLogFiles(string $modelClass, Collection $ids): void
{
$logs = DB::table('action_logs')
->select('action_type', 'item_type', 'filename', 'accept_signature')
->where(function ($outer) use ($modelClass, $ids) {
$outer->where(function ($s) use ($modelClass, $ids) {
$s->where('item_type', $modelClass)->whereIn('item_id', $ids);
})->orWhere(function ($s) use ($modelClass, $ids) {
$s->where('target_type', $modelClass)->whereIn('target_id', $ids);
});
})
->get();
$paths = [];
foreach ($logs as $log) {
if (! empty($log->filename)) {
$path = $this->actionLogFilePath($log->action_type, $log->item_type, $log->filename);
if ($path !== null) {
$paths[] = $path;
}
}
if (! empty($log->accept_signature)) {
$paths[] = 'private_uploads/signatures/'.$log->accept_signature;
}
}
foreach (array_unique($paths) as $path) {
$this->tryUnlink($path);
}
}
/**
* Map an action_log entry to the disk path of its attached file, or
* null if the log carries no attachment we know how to route. Mirrors
* the logic in `Actionlog::uploads_file_path()` but kept inline here
* so purge can work off raw query-builder rows (no Eloquent).
*/
private function actionLogFilePath(?string $actionType, ?string $itemType, string $filename): ?string
{
if ($actionType === 'accepted' || $actionType === 'declined') {
return 'private_uploads/eula-pdfs/'.$filename;
}
if ($actionType === 'audit') {
return 'private_uploads/audits/'.$filename;
}
if ($itemType !== null && isset(self::UPLOAD_ROOTS[$itemType])) {
return rtrim(self::UPLOAD_ROOTS[$itemType], '/').'/'.$filename;
}
return null;
}
/**
* Delete files referenced by columns on the parent row itself
* (as opposed to action_logs). Covers CheckoutAcceptance's
* `signature_filename` and `stored_eula_file`, which store their
* paths inline on the row rather than in a related action_log.
*/
private function deletePrivateFileColumns(string $modelClass, string $table, Collection $ids): void
{
foreach (self::PRIVATE_FILE_COLUMNS[$modelClass] ?? [] as $column => $subpath) {
$filenames = DB::table($table)
->whereIn('id', $ids)
->pluck($column)
->filter()
->unique();
foreach ($filenames as $filename) {
$this->tryUnlink(rtrim($subpath, '/').'/'.basename($filename));
}
}
}
/**
* Storage::delete with a log-and-continue on failure. All private-
* disk unlink calls funnel through here so error handling stays
* uniform.
*/
private function tryUnlink(string $key): void
{
try {
if (Storage::exists($key)) {
Storage::delete($key);
}
} catch (\Exception $e) {
Log::info('snipeit:purge - error deleting '.$key.': '.$e->getMessage());
}
}
}