Files
rspade_system/app/RSpade/Commands/Rsx/RsxStorageCleanupCommand.php
root 29c657f7a7 Exclude tests directory from framework publish
Add 100+ automated unit tests from .expect file specifications
Add session system test
Add rsx:constants:regenerate command test
Add rsx:logrotate command test
Add rsx:clean command test
Add rsx:manifest:stats command test
Add model enum system test
Add model mass assignment prevention test
Add rsx:check command test
Add migrate:status command test

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-25 03:59:58 +00:00

206 lines
6.7 KiB
PHP

<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Commands\Rsx;
use App\Console\Commands\FrameworkDeveloperCommand;
use App\Models\File_Storage_Model;
use Rsx\Models\File_Attachment_Model;
use App\RSpade\Core\Locks\RsxLocks;
use Illuminate\Support\Facades\DB;
/**
* Storage Cleanup Command
* ========================
*
* PURPOSE:
* Force cleanup of orphaned storage and attachments.
*
* FRAMEWORK DEVELOPER ONLY:
* This command is hidden unless IS_FRAMEWORK_DEVELOPER=true in .env
*
* CLEANUP OPERATIONS:
* 1. Delete orphaned attachments (no fileable, older than orphan-age hours)
* 2. Delete orphaned storage (no attachments)
* 3. Delete physical files for deleted storage
*
* SAFETY:
* - Supports --dry-run to preview operations
* - Requires --force to skip confirmation
* - Uses file write lock to prevent race conditions
*/
class RsxStorageCleanupCommand extends FrameworkDeveloperCommand
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'rsx:storage:cleanup
{--dry-run : Show what would be deleted without deleting}
{--force : Skip confirmation prompts}
{--orphan-age=24 : Hours before orphaned attachment is deleted}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Force cleanup of orphaned storage and attachments';
/**
* Execute the console command.
*/
public function handle()
{
$dry_run = $this->option('dry-run');
$force = $this->option('force');
$orphan_age = (int)$this->option('orphan-age');
$this->info('');
$this->info('File Storage Cleanup');
$this->info('====================');
$this->info('');
if ($dry_run) {
$this->warn('DRY RUN MODE - No files will be deleted');
$this->info('');
}
// Find orphaned attachments
$orphaned_attachments = File_Attachment_Model::whereNull('fileable_type')
->whereNull('fileable_id')
->where('created_at', '<', now()->subHours($orphan_age))
->get();
// Find orphaned storage
$orphaned_storage = DB::table('_file_storage')
->leftJoin('file_attachments', 'file_storage.id', '=', 'file_attachments.file_storage_id')
->whereNull('file_attachments.id')
->select('file_storage.*')
->get();
$this->info("Found {$orphaned_attachments->count()} orphaned attachments (>{$orphan_age}h)");
$this->info("Found {$orphaned_storage->count()} orphaned storage records");
$this->info('');
if ($orphaned_attachments->isEmpty() && $orphaned_storage->isEmpty()) {
$this->info('Nothing to clean up');
return 0;
}
// Show details
if ($orphaned_attachments->isNotEmpty()) {
$this->info('Orphaned Attachments:');
foreach ($orphaned_attachments->take(10) as $attachment) {
$age_hours = round($attachment->created_at->diffInHours(now()), 1);
$this->info(" - {$attachment->file_name} (key: " . substr($attachment->key, 0, 16) . "..., age: {$age_hours}h)");
}
if ($orphaned_attachments->count() > 10) {
$remaining = $orphaned_attachments->count() - 10;
$this->info(" ... and {$remaining} more");
}
$this->info('');
}
if ($orphaned_storage->isNotEmpty()) {
$this->info('Orphaned Storage:');
foreach ($orphaned_storage->take(10) as $storage) {
$storage_obj = File_Storage_Model::find($storage->id);
$size = $storage_obj ? $storage_obj->get_human_size() : 'unknown';
$this->info(" - " . substr($storage->hash, 0, 24) . "... ({$size})");
}
if ($orphaned_storage->count() > 10) {
$remaining = $orphaned_storage->count() - 10;
$this->info(" ... and {$remaining} more");
}
$this->info('');
}
// Confirmation
if (!$dry_run && !$force) {
if (!$this->confirm('Proceed with cleanup?', false)) {
$this->info('Cleanup cancelled');
return 0;
}
$this->info('');
}
if ($dry_run) {
$this->info('[DRY RUN] Would delete:');
$this->info(" - {$orphaned_attachments->count()} orphaned attachments");
$this->info(" - {$orphaned_storage->count()} orphaned storage records");
$this->info('');
return 0;
}
// Acquire file write lock
$lock = RsxLocks::get_lock(
RsxLocks::SERVER_LOCK,
RsxLocks::LOCK_FILE_WRITE,
RsxLocks::WRITE_LOCK,
60
);
try {
$deleted_attachments = 0;
$deleted_storage = 0;
$deleted_bytes = 0;
// Delete orphaned attachments
foreach ($orphaned_attachments as $attachment) {
$attachment->delete();
$deleted_attachments++;
}
// Delete orphaned storage
foreach ($orphaned_storage as $storage_data) {
$storage = File_Storage_Model::find($storage_data->id);
if ($storage) {
$deleted_bytes += $storage->size;
// Delete physical file
$file_path = $storage->get_full_path();
if (file_exists($file_path)) {
unlink($file_path);
}
// Delete record
$storage->delete();
$deleted_storage++;
}
}
$this->info('[OK] Cleanup completed');
$this->info('');
$this->info(" Deleted Attachments: {$deleted_attachments}");
$this->info(" Deleted Storage: {$deleted_storage}");
$this->info(" Freed Disk Space: " . $this->format_bytes($deleted_bytes));
$this->info('');
return 0;
} finally {
RsxLocks::release_lock($lock);
}
}
/**
* Format bytes to human-readable size
*/
protected function format_bytes($bytes)
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
$bytes /= 1024;
}
return round($bytes, 2) . ' ' . $units[$i];
}
}