Backup
2 months ago
FileList
4 months ago
Restore
2 months ago
Backup.php
2 years ago
BackupDownloader.php
1 day ago
BackupSizeCalculator.php
1 day ago
BackupSpeedIndex.php
9 months ago
BaseFileList.php
1 year ago
BaseListing.php
9 months ago
Delete.php
4 months ago
Edit.php
4 months ago
Explore.php
3 months ago
ExploreCache.php
3 months ago
FileInfo.php
9 months ago
FileList.php
1 year ago
Listing.php
7 months ago
Parts.php
1 day ago
Restore.php
2 years ago
ScheduleList.php
1 year ago
Upload.php
1 month ago
ExploreCache.php
266 lines
| 1 | <?php |
| 2 | |
| 3 | namespace WPStaging\Backup\Ajax; |
| 4 | |
| 5 | use WPStaging\Backup\BackupFileIndex; |
| 6 | use WPStaging\Backup\Entity\BackupMetadata; |
| 7 | use WPStaging\Backup\FileHeader; |
| 8 | use WPStaging\Core\WPStaging; |
| 9 | use WPStaging\Framework\Filesystem\FileObject; |
| 10 | use WPStaging\Framework\Filesystem\Filesystem; |
| 11 | use WPStaging\Framework\Filesystem\PathIdentifier; |
| 12 | use WPStaging\Framework\Utils\Cache\Cache; |
| 13 | |
| 14 | /** |
| 15 | * Caches the parsed backup file index as a pre-built folder tree. |
| 16 | * |
| 17 | * Parses the full index once on first request and stores a flat associative |
| 18 | * array keyed by folder path. Subsequent requests become O(1) lookups |
| 19 | * instead of full sequential index scans. |
| 20 | */ |
| 21 | class ExploreCache |
| 22 | { |
| 23 | /** @var int Cache lifetime in seconds (1 hour) */ |
| 24 | const LIFETIME = 3600; |
| 25 | |
| 26 | /** @var int Max cache file size in bytes before falling back to direct scan (20 MB) */ |
| 27 | const MAX_CACHE_SIZE = 20 * 1024 * 1024; |
| 28 | |
| 29 | /** @var Cache */ |
| 30 | private $cache; |
| 31 | |
| 32 | /** @var PathIdentifier */ |
| 33 | private $pathIdentifier; |
| 34 | |
| 35 | /** @var Filesystem */ |
| 36 | private $filesystem; |
| 37 | |
| 38 | public function __construct(Cache $cache, PathIdentifier $pathIdentifier, Filesystem $filesystem) |
| 39 | { |
| 40 | $this->cache = $cache; |
| 41 | $this->pathIdentifier = $pathIdentifier; |
| 42 | $this->filesystem = $filesystem; |
| 43 | } |
| 44 | |
| 45 | /** |
| 46 | * Get cached folder tree or build it from the backup index |
| 47 | * |
| 48 | * @param string $backupFile Absolute path to the .wpstg backup file |
| 49 | * @param BackupMetadata $metadata |
| 50 | * @return array|null Folder tree array keyed by folder path, or null on failure |
| 51 | */ |
| 52 | public function getOrBuild(string $backupFile, BackupMetadata $metadata) |
| 53 | { |
| 54 | $this->configureCache($backupFile); |
| 55 | |
| 56 | $cached = $this->read($backupFile); |
| 57 | if ($cached !== null) { |
| 58 | return $cached; |
| 59 | } |
| 60 | |
| 61 | $tree = $this->buildTree($backupFile, $metadata); |
| 62 | if ($tree === null) { |
| 63 | return null; |
| 64 | } |
| 65 | |
| 66 | $this->write($backupFile, $tree); |
| 67 | |
| 68 | return $tree; |
| 69 | } |
| 70 | |
| 71 | /** |
| 72 | * @param string $backupFile |
| 73 | */ |
| 74 | private function configureCache(string $backupFile) |
| 75 | { |
| 76 | $this->cache->setLifetime(self::LIFETIME); |
| 77 | $this->cache->setFilename('backup_explore_' . md5($backupFile)); |
| 78 | } |
| 79 | |
| 80 | /** |
| 81 | * Read and validate the cached data |
| 82 | * |
| 83 | * @param string $backupFile |
| 84 | * @return array|null |
| 85 | */ |
| 86 | private function read(string $backupFile) |
| 87 | { |
| 88 | if (!$this->cache->isValid(false)) { |
| 89 | return null; |
| 90 | } |
| 91 | |
| 92 | $filePath = $this->cache->getFilePath(); |
| 93 | if (filesize($filePath) > self::MAX_CACHE_SIZE) { |
| 94 | return null; |
| 95 | } |
| 96 | |
| 97 | $data = $this->cache->get(); |
| 98 | if (!is_array($data) || !isset($data['mtime'], $data['tree'])) { |
| 99 | return null; |
| 100 | } |
| 101 | |
| 102 | $currentMtime = @filemtime($backupFile); |
| 103 | if ($currentMtime === false || (int)$data['mtime'] !== $currentMtime) { |
| 104 | return null; |
| 105 | } |
| 106 | |
| 107 | return $data['tree']; |
| 108 | } |
| 109 | |
| 110 | /** |
| 111 | * Write the folder tree to cache |
| 112 | * |
| 113 | * @param string $backupFile |
| 114 | * @param array $tree |
| 115 | */ |
| 116 | private function write(string $backupFile, array $tree) |
| 117 | { |
| 118 | $mtime = @filemtime($backupFile); |
| 119 | if ($mtime === false) { |
| 120 | return; |
| 121 | } |
| 122 | |
| 123 | $this->cache->save([ |
| 124 | 'mtime' => $mtime, |
| 125 | 'tree' => $tree, |
| 126 | ]); |
| 127 | } |
| 128 | |
| 129 | /** |
| 130 | * Build the full folder tree by scanning the backup index once |
| 131 | * |
| 132 | * @param string $backupFile |
| 133 | * @param BackupMetadata $metadata |
| 134 | * @return array |
| 135 | */ |
| 136 | private function buildTree(string $backupFile, BackupMetadata $metadata) |
| 137 | { |
| 138 | $indexLineDto = $this->createIndexLineDto($metadata); |
| 139 | $fileObject = new FileObject($backupFile, FileObject::MODE_READ); |
| 140 | $fileObject->fseek((int)$metadata->getHeaderStart()); |
| 141 | |
| 142 | // tree[folderPath] = ['dirs' => [...], 'files' => [...]] |
| 143 | $tree = ['' => ['dirs' => [], 'files' => []]]; |
| 144 | // Track child counts and subdirectory flags per folder per direct child dir |
| 145 | $dirChildren = []; |
| 146 | $dirHasSubdirs = []; |
| 147 | |
| 148 | while ($fileObject->valid() && $fileObject->ftell() < (int)$metadata->getHeaderEnd()) { |
| 149 | $indexOffset = $fileObject->ftell(); |
| 150 | $rawIndexFile = $fileObject->readAndMoveNext(); |
| 151 | if (!$indexLineDto->isIndexLine($rawIndexFile)) { |
| 152 | continue; |
| 153 | } |
| 154 | |
| 155 | $backupFileIndex = $indexLineDto->readIndexLine($rawIndexFile); |
| 156 | $relativePath = $this->pathIdentifier->transformIdentifiableToRelativePath($backupFileIndex->getIdentifiablePath()); |
| 157 | $relativePath = $this->filesystem->normalizePath($relativePath); |
| 158 | |
| 159 | if ($relativePath === '' || $relativePath === '/') { |
| 160 | continue; |
| 161 | } |
| 162 | |
| 163 | $lastSlash = strrpos($relativePath, '/'); |
| 164 | $parentDir = $lastSlash === false ? '' : substr($relativePath, 0, $lastSlash); |
| 165 | $fileName = $lastSlash === false ? $relativePath : substr($relativePath, $lastSlash + 1); |
| 166 | |
| 167 | if ($fileName === '') { |
| 168 | continue; |
| 169 | } |
| 170 | |
| 171 | // Ensure parent folder bucket exists |
| 172 | if (!isset($tree[$parentDir])) { |
| 173 | $tree[$parentDir] = ['dirs' => [], 'files' => []]; |
| 174 | } |
| 175 | |
| 176 | // Check if this file is nested deeper — means it belongs to a subdirectory of some ancestor |
| 177 | // Register directory entries for every ancestor level |
| 178 | $parts = explode('/', $relativePath); |
| 179 | $depth = count($parts); |
| 180 | |
| 181 | if ($depth > 1) { |
| 182 | // Register this file's directory chain |
| 183 | $currentPath = ''; |
| 184 | for ($i = 0; $i < $depth - 1; $i++) { |
| 185 | $dirName = $parts[$i]; |
| 186 | $childPath = $currentPath === '' ? $dirName : $currentPath . '/' . $dirName; |
| 187 | |
| 188 | if (!isset($tree[$currentPath])) { |
| 189 | $tree[$currentPath] = ['dirs' => [], 'files' => []]; |
| 190 | } |
| 191 | |
| 192 | // Track this directory as a child of the current path |
| 193 | if (!isset($tree[$currentPath]['dirs'][$dirName])) { |
| 194 | $tree[$currentPath]['dirs'][$dirName] = [ |
| 195 | 'name' => $dirName, |
| 196 | 'path' => $childPath, |
| 197 | 'hasChildren' => false, |
| 198 | ]; |
| 199 | } |
| 200 | |
| 201 | // Track direct children count for item counting |
| 202 | if ($i + 1 < $depth - 1) { |
| 203 | // This is an intermediate directory — mark it has subdirs |
| 204 | $dirHasSubdirs[$childPath] = true; |
| 205 | } |
| 206 | |
| 207 | // Count direct children of this directory (files + immediate subdirs) |
| 208 | $nextPart = $parts[$i + 1]; |
| 209 | $dirChildren[$childPath][$nextPart] = true; |
| 210 | |
| 211 | $currentPath = $childPath; |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | // Add file to its direct parent |
| 216 | $size = (int)$backupFileIndex->getUncompressedSize(); |
| 217 | $tree[$parentDir]['files'][] = [ |
| 218 | 'name' => $fileName, |
| 219 | 'path' => $relativePath, |
| 220 | 'size' => $size, |
| 221 | 'offset' => (int)$indexOffset, |
| 222 | ]; |
| 223 | } |
| 224 | |
| 225 | $fileObject = null; |
| 226 | |
| 227 | // Set hasChildren and items counts on directory entries |
| 228 | foreach ($tree as $folderPath => &$bucket) { |
| 229 | $dirArray = []; |
| 230 | foreach ($bucket['dirs'] as $dirName => $dirData) { |
| 231 | $dirPath = $dirData['path']; |
| 232 | $dirData['hasChildren'] = isset($dirChildren[$dirPath]) && count($dirChildren[$dirPath]) > 0; |
| 233 | $dirData['items'] = isset($dirChildren[$dirPath]) ? count($dirChildren[$dirPath]) : 0; |
| 234 | $dirArray[] = $dirData; |
| 235 | } |
| 236 | |
| 237 | usort($dirArray, function ($a, $b) { |
| 238 | return strcasecmp($a['name'], $b['name']); |
| 239 | }); |
| 240 | |
| 241 | $bucket['dirs'] = $dirArray; |
| 242 | |
| 243 | usort($bucket['files'], function ($a, $b) { |
| 244 | return strcasecmp($a['name'], $b['name']); |
| 245 | }); |
| 246 | } |
| 247 | |
| 248 | unset($bucket); |
| 249 | |
| 250 | return $tree; |
| 251 | } |
| 252 | |
| 253 | /** |
| 254 | * @param BackupMetadata $metadata |
| 255 | * @return \WPStaging\Backup\Interfaces\IndexLineInterface |
| 256 | */ |
| 257 | private function createIndexLineDto(BackupMetadata $metadata) |
| 258 | { |
| 259 | if ($metadata->getIsBackupFormatV1()) { |
| 260 | return new BackupFileIndex(); |
| 261 | } |
| 262 | |
| 263 | return WPStaging::make(FileHeader::class); |
| 264 | } |
| 265 | } |
| 266 |