src/FileCacheCleaner.php
<?php
/**
* File Cache Cleaner
* - Delete expired Laravel-style `Illuminate\Cache` cache files
* - https://github.com/attogram/file-cache-cleaner
*/
declare(strict_types = 1);
namespace Attogram\Cache;
use DirectoryIterator;
use FilesystemIterator;
use RecursiveCallbackFilterIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use function array_reverse;
use function file_get_contents;
use function getopt;
use function gmdate;
use function is_dir;
use function preg_match;
use function print_r;
use function realpath;
use function rmdir;
use function strlen;
use function time;
use function unlink;
class FileCacheCleaner
{
/** @var string Code Version */
const VERSION = '2.5.0';
/** @var string Date Format for gmdate() */
const DATE_FORMAT = 'Y-m-d H:i:s';
/** @var string Usage */
const USAGE = "Attogram File Cache Cleaner Usage:\n"
. " file-cache-cleaner --directory cacheDirectory --clean\n"
. " Options:\n"
. " -d path or --directory path - set path to Cache Directory\n"
. " -c or --clean - clean cache: delete expired files, remove empty subdirectories\n\n";
/** @var string $cacheDirectory - top-level of Cache Directory to be cleaned */
private $cacheDirectory = '';
/** @var array $subDirectoryList - list of all sub-directories in the Cache Directory */
private $subDirectoryList = [];
/** @var int $expirationCheckTime - Cache expiration date used for cleaning, in unix timestamp format */
private $expirationCheckTime = 0;
/** @var bool $clean - clean cache directory? */
private $clean = false;
/** @var array $report - report on cache status */
private $report = [];
/**
* Clean The Cache Directory - delete expired cache files and empty directories
*/
public function clean()
{
$this->verbose('attogram/file-cache-cleaner v' . self::VERSION);
$this->setOptions();
$this->examineCache();
$this->examineCacheSubdirectories();
$this->showReport();
}
private function setOptions()
{
$options = getopt('d:c', ['directory:', 'clean']);
if (!$options) {
print self::USAGE;
$this->fatalError('Please specify --directory and --clean');
}
// -d or --directory - set Cache Directory
$cacheDirectory = !empty($options['d']) ? $options['d'] : '';
$cacheDirectory = !empty($options['directory']) ? $options['directory'] : $cacheDirectory;
$this->setCacheDirectory($cacheDirectory);
// -c or --clean - turn on Cleaning mode
$this->clean = isset($options['c']) ? true : false;
$this->clean = isset($options['clean']) ? true : $this->clean;
$this->verbose('Cleaning Mode: ' . ($this->clean ? 'On' : 'Off'));
// expiration comparison time
$this->expirationCheckTime = time();
$this->verbose('Expiration Check Time: ' . gmdate(self::DATE_FORMAT, $this->expirationCheckTime) . ' UTC');
}
/**
* @param string $directory (default '')
*/
private function setCacheDirectory(string $directory = '')
{
if (!$directory) {
print self::USAGE;
$this->fatalError('Missing Cache Directory. Please specify with -d or --directory');
}
if (!is_dir($directory)) {
print self::USAGE;
$this->fatalError('Cache Directory Not Found: ' . $directory);
}
$this->cacheDirectory = realpath($directory);
$this->verbose('Cache Directory: ' . $this->cacheDirectory);
}
/**
* Examine cache files and cache subdirectories
*/
private function examineCache()
{
$objects = new RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator($this->cacheDirectory, FilesystemIterator::SKIP_DOTS),
function ($current) {
$filename = $current->getFileName();
// is cache directory? filename must be 2 characters, alphanumeric only
if ($current->isDir()
&& strlen($filename) == 2
&& preg_match('/^([a-z0-9]+)$/', $filename)
) {
return true;
}
// is cache file? filename must be 40 characters, alphanumeric only
if ($current->isFile()
&& strlen($filename) == 40
&& preg_match('/^([a-z0-9]+)$/', $filename)
) {
return true;
}
return false;
}
);
$iterator = new RecursiveIteratorIterator($objects, RecursiveIteratorIterator::SELF_FIRST);
foreach ($iterator as $splFileInfo) {
$this->findCacheFile($splFileInfo);
$this->findCacheSubdirectory($splFileInfo);
}
}
/**
* @param \SplFileInfo $splFileInfo
*/
private function findCacheFile($splFileInfo)
{
if (!$splFileInfo->isFile()) {
return;
}
$this->examineCacheFile($splFileInfo);
}
/**
* @param \SplFileInfo $splFileInfo
*/
private function examineCacheFile($splFileInfo)
{
$size = $splFileInfo->getSize();
$pathname = $splFileInfo->getPathName();
$this->incrementReport('cache_files');
$this->incrementReport('cache_files_size', $size);
$timestamp = $this->getFileCacheExpiration($pathname);
if ($timestamp > $this->expirationCheckTime) { // If file cache is Not Expired yet
$this->incrementReport('unexpired_cache_files');
$this->incrementReport('unexpired_cache_files_size', $size);
return;
}
$this->incrementReport('expired_cache_files');
$this->incrementReport('expired_cache_files_size', $size);
if (!$this->clean) {
return;
}
if (unlink($pathname)) {
$this->incrementReport('deleted_expired_cache_files');
$this->incrementReport('deleted_expired_cache_files_size', $size);
return;
}
$this->incrementReport('errors');
}
/**
* @param \SplFileInfo $splFileInfo
*/
private function findCacheSubdirectory($splFileInfo)
{
if (!$splFileInfo->isDir()) {
return;
}
// Save subdirectories to list
$this->incrementReport('cache_subdirectories');
$this->subDirectoryList[] = $splFileInfo->getPathName();
}
/**
* @param string $pathname - path and filename
* @return int - expiration time as unix timestamp, or 9999999999 on error
*/
private function getFileCacheExpiration(string $pathname): int
{
// Get expiration time from an Illuminate\Cache File
// as a unix timestamp, from the first 10 characters in the file
$timestamp = file_get_contents($pathname, false, null, 0, 10);
if (!$timestamp // if timestamp not found
|| strlen($timestamp) != 10 // if timestamp is Not 10 characters long
|| !preg_match('/^([0-9]+)$/', $timestamp) // if timestamp is Not numbers-only
) {
$this->incrementReport('invalid_timestamp_cache_files');
return 9999999999; // max time 2286-11-20 17:46:39
}
$timestamp = (int) $timestamp;
$this->reportExpiration($timestamp);
return $timestamp;
}
/**
* Report shortest/longest expiration times
* @param int $timestamp (unix timestamp)
*/
private function reportExpiration($timestamp)
{
if (empty($this->report['expirationShortest'])
|| $timestamp <= $this->report['expirationShortest']
) {
$this->report['expirationShortest'] = $timestamp;
}
if (empty($this->report['expirationLongest'])
|| $timestamp >= $this->report['expirationLongest']
) {
$this->report['expirationLongest'] = $timestamp;
}
}
/**
* Remove Empty Subdirectories
*/
private function examineCacheSubdirectories()
{
// reverse array of subdirectories so we start from last item
foreach (array_reverse($this->subDirectoryList) as $directory) {
if ($this->isEmptyDirectory($directory)) {
$this->incrementReport('empty_cache_subdirectories');
$this->removeDirectory($directory);
}
}
}
/**
* Is the Directory Empty?
* @param string $directory
* @return bool
*/
private function isEmptyDirectory($directory)
{
foreach (new DirectoryIterator($directory) as $thing) {
if (!$thing->isDot() && ($thing->isFile() || $thing->isDir())) {
return false;
}
}
return true;
}
/**
* Remove the Directory
* @param string $directory
*/
private function removeDirectory($directory)
{
if (!$this->clean) {
return;
}
if (rmdir($directory)) {
$this->incrementReport('deleted_empty_cache_subdirectories');
return;
}
$this->incrementReport('errors');
}
private function showReport()
{
$this->verbose(
"---------- Cache ----------\n"
. $this->getReport('cache_files') . " cache files, "
. $this->getReport('cache_files_size') . " bytes\n"
. $this->getReport('unexpired_cache_files') . " unexpired cache files, "
. $this->getReport('unexpired_cache_files_size') . " bytes\n"
. $this->getReport('expired_cache_files') . " expired cache files, "
. $this->getReport('expired_cache_files_size') . " bytes\n"
. $this->getReport('deleted_expired_cache_files') . " deleted expired cache files, "
. $this->getReport('deleted_expired_cache_files_size') . " bytes\n"
. $this->getReportDate('expirationShortest') . " UTC: Shortest Expiration Time\n"
. $this->getReportDate('expirationLongest') . " UTC: Longest Expiration Time\n"
. "---------- Subdirectories ----------\n"
. $this->getReport('cache_subdirectories') . " cache subdirectories\n"
. $this->getReport('empty_cache_subdirectories') . " empty cache subdirectories\n"
. $this->getReport('deleted_empty_cache_subdirectories') . " deleted empty cache subdirectories\n"
. "---------- Misc ----------\n"
. $this->getReport('invalid_timestamp_cache_files') . " invalid timestamp cache files\n"
. $this->getReport('errors') . " errors"
);
}
/**
* Increment report value
* @param string $key
* @param int $value (optional)
*/
private function incrementReport($key, $value = 0)
{
$increment = $value ? $value : 1;
if (empty($this->report[$key])) {
$this->report[$key] = $increment;
return;
}
$this->report[$key] = $this->report[$key] + $increment;
}
/**
* @param string $key
* @return string
*/
private function getReport($key)
{
if (isset($this->report[$key])) {
return number_format($this->report[$key]);
}
return '0';
}
/**
* @param string $key
* @return string
*/
private function getReportDate($key)
{
if (empty($this->report[$key])) {
return ' - ';
}
return gmdate(self::DATE_FORMAT, $this->report[$key]);
}
/**
* @param mixed $msg (optional)
*/
private function verbose($msg = '')
{
print print_r($msg, true) . "\n";
}
/**
* @param mixed $msg (optional)
*/
private function fatalError($msg = '')
{
exit('FATAL ERROR: ' . print_r($msg, true));
}
}