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:
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user