From 3d48220b3327021e91ae6bef9c3cb66cf150705b Mon Sep 17 00:00:00 2001 From: snipe Date: Tue, 21 Jul 2026 16:44:33 +0100 Subject: [PATCH] Purge: More refactors from from #19333 --- app/Console/Commands/Purge.php | 327 +++++++++++++++++++++++++++------ 1 file changed, 268 insertions(+), 59 deletions(-) diff --git a/app/Console/Commands/Purge.php b/app/Console/Commands/Purge.php index 99031d6f55..82d250f7ea 100644 --- a/app/Console/Commands/Purge.php +++ b/app/Console/Commands/Purge.php @@ -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 */ @@ -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()); + } + } }