> /var/log/happybums_cleanup.log 2>&1 // // SECURITY NOTES // -------------- // Web access is intentionally low-risk: the worst outcome is that // stale data is removed slightly ahead of schedule. No active wearer // data can be lost as the 365-day threshold far exceeds any normal // period of inactivity. Consider restricting web access via .htaccess // if you prefer CLI-only operation. // // ============================================================ // Configuration // ============================================================ // Path to the SQLite database file. // __DIR__ means "same directory as this script", matching HappyBums.php behaviour. // Change this if you place the script elsewhere. $dbFile = __DIR__ . '/happybums.db'; // Wearers not seen within this many days will be removed. $daysThreshold = 365; // ============================================================ // Detect run context and dry-run flag // ============================================================ $isCLI = (php_sapi_name() === 'cli'); $dryRun = false; if ($isCLI) { $dryRun = in_array('--dry-run', $argv ?? []); } else { $dryRun = isset($_GET['dry_run']) && $_GET['dry_run'] === '1'; } // ============================================================ // Output helpers // ============================================================ function out($line) { global $isCLI; if ($isCLI) { echo $line . PHP_EOL; } else { echo htmlspecialchars($line) . "
\n"; } } function outSection($title) { global $isCLI; $bar = str_repeat('-', 60); if ($isCLI) { echo PHP_EOL . $bar . PHP_EOL . $title . PHP_EOL . $bar . PHP_EOL; } else { echo "
" . htmlspecialchars($title) . "
\n"; } } // ============================================================ // HTML wrapper (web mode only) // ============================================================ if (!$isCLI) { header('Content-Type: text/html; charset=utf-8'); echo ""; echo "HappyBums System - DB Cleanup"; echo ""; echo ""; echo "

HappyBums System — Database Housekeeping

\n"; } // ============================================================ // Main // ============================================================ out("HappyBums System - Database Cleanup"); out("Run mode : " . ($isCLI ? "CLI" : "Web")); out("Dry run : " . ($dryRun ? "YES (no changes will be made)" : "NO (live run)")); out("Threshold : " . $daysThreshold . " days"); out("Timestamp : " . date('Y-m-d H:i:s')); // ---- Open database ---- if (!file_exists($dbFile)) { out("ERROR: Database file not found: " . $dbFile); if (!$isCLI) { echo ""; } exit(1); } try { $db = new PDO('sqlite:' . $dbFile); $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $db->exec('PRAGMA journal_mode=WAL'); } catch (Exception $e) { out("ERROR: Could not open database: " . $e->getMessage()); if (!$isCLI) { echo ""; } exit(1); } // ---- Calculate cutoff timestamp ---- $cutoff = time() - ($daysThreshold * 86400); out("Cutoff : " . date('Y-m-d H:i:s', $cutoff) . " (unix: " . $cutoff . ")"); // ---- Step 1: Find stale wearers ---- outSection("Step 1 - Stale wearers (last_seen older than $daysThreshold days)"); try { $stmt = $db->prepare( "SELECT access_uuid, wearer_name, last_seen FROM wearers WHERE last_seen < :cutoff ORDER BY last_seen ASC" ); $stmt->execute([':cutoff' => $cutoff]); $staleWearers = $stmt->fetchAll(); } catch (Exception $e) { out("ERROR querying wearers: " . $e->getMessage()); if (!$isCLI) { echo ""; } exit(1); } if (empty($staleWearers)) { out("No stale wearers found."); } else { out("Found " . count($staleWearers) . " stale wearer(s):"); foreach ($staleWearers as $row) { $lastSeen = $row['last_seen'] > 0 ? date('Y-m-d', $row['last_seen']) : 'never'; $name = $row['wearer_name'] !== '' ? $row['wearer_name'] : '(unnamed)'; out(" " . $row['access_uuid'] . " " . $name . " last seen: " . $lastSeen); } } // ---- Step 2: Delete stale wearers ---- outSection("Step 2 - Deleting stale wearers"); $deletedWearers = 0; if (!empty($staleWearers)) { if ($dryRun) { out("DRY RUN: would delete " . count($staleWearers) . " row(s) from wearers."); $deletedWearers = count($staleWearers); // for cascade preview count } else { try { $stmt = $db->prepare("DELETE FROM wearers WHERE last_seen < :cutoff"); $stmt->execute([':cutoff' => $cutoff]); $deletedWearers = $stmt->rowCount(); out("Deleted " . $deletedWearers . " row(s) from wearers."); } catch (Exception $e) { out("ERROR deleting from wearers: " . $e->getMessage()); if (!$isCLI) { echo ""; } exit(1); } } } else { out("Nothing to delete."); } // ---- Step 3: Cascade - orphaned owner_wearers ---- outSection("Step 3 - Orphaned rows in owner_wearers"); try { $stmt = $db->query( "SELECT COUNT(*) AS cnt FROM owner_wearers WHERE access_uuid NOT IN (SELECT access_uuid FROM wearers)" ); $orphanOW = (int)$stmt->fetch()['cnt']; } catch (Exception $e) { out("ERROR querying owner_wearers: " . $e->getMessage()); if (!$isCLI) { echo ""; } exit(1); } out("Found " . $orphanOW . " orphaned row(s) in owner_wearers."); if ($orphanOW > 0) { if ($dryRun) { out("DRY RUN: would delete " . $orphanOW . " row(s) from owner_wearers."); } else { try { $stmt = $db->query( "DELETE FROM owner_wearers WHERE access_uuid NOT IN (SELECT access_uuid FROM wearers)" ); out("Deleted " . $stmt->rowCount() . " row(s) from owner_wearers."); } catch (Exception $e) { out("ERROR deleting from owner_wearers: " . $e->getMessage()); if (!$isCLI) { echo ""; } exit(1); } } } // ---- Step 4: Cascade - orphaned nicknames ---- outSection("Step 4 - Orphaned rows in nicknames"); try { $stmt = $db->query( "SELECT COUNT(*) AS cnt FROM nicknames WHERE target_uuid NOT IN (SELECT access_uuid FROM wearers)" ); $orphanNick = (int)$stmt->fetch()['cnt']; } catch (Exception $e) { out("ERROR querying nicknames: " . $e->getMessage()); if (!$isCLI) { echo ""; } exit(1); } out("Found " . $orphanNick . " orphaned row(s) in nicknames."); if ($orphanNick > 0) { if ($dryRun) { out("DRY RUN: would delete " . $orphanNick . " row(s) from nicknames."); } else { try { $stmt = $db->query( "DELETE FROM nicknames WHERE target_uuid NOT IN (SELECT access_uuid FROM wearers)" ); out("Deleted " . $stmt->rowCount() . " row(s) from nicknames."); } catch (Exception $e) { out("ERROR deleting from nicknames: " . $e->getMessage()); if (!$isCLI) { echo ""; } exit(1); } } } // ---- Step 5: Orphaned owners (no wearers linked) ---- outSection("Step 5 - Orphaned owners (no linked wearers remaining)"); // Note: this only removes owners who have NO wearers left at all. // Owners who still have at least one active wearer are untouched. try { $stmt = $db->query( "SELECT COUNT(*) AS cnt FROM owners WHERE owner_uuid NOT IN (SELECT owner_uuid FROM owner_wearers)" ); $orphanOwners = (int)$stmt->fetch()['cnt']; } catch (Exception $e) { out("ERROR querying owners: " . $e->getMessage()); if (!$isCLI) { echo ""; } exit(1); } out("Found " . $orphanOwners . " owner(s) with no remaining linked wearers."); if ($orphanOwners > 0) { if ($dryRun) { out("DRY RUN: would delete " . $orphanOwners . " row(s) from owners."); } else { try { $stmt = $db->query( "DELETE FROM owners WHERE owner_uuid NOT IN (SELECT owner_uuid FROM owner_wearers)" ); out("Deleted " . $stmt->rowCount() . " row(s) from owners."); } catch (Exception $e) { out("ERROR deleting from owners: " . $e->getMessage()); if (!$isCLI) { echo ""; } exit(1); } } } // ---- Done ---- outSection("Complete"); out($dryRun ? "Dry run complete. No data was modified." : "Housekeeping complete."); if (!$isCLI) { echo "

To run a live cleanup, remove the ?dry_run=1 parameter from the URL.

"; echo ""; } exit(0);