| 1 |
<?php |
| 2 |
|
| 3 |
declare (strict_types=1); |
| 4 |
namespace WindPress\WindPress\Core\Scanner; |
| 5 |
|
| 6 |
use InvalidArgumentException; |
| 7 |
use RuntimeException; |
| 8 |
use WIND_PRESS; |
| 9 |
/** |
| 10 |
* Immutable source manifests and resumable, deletion-aware deltas. |
| 11 |
*/ |
| 12 |
class SourceIndex |
| 13 |
{ |
| 14 |
private const JOB_TTL = 900; |
| 15 |
private const MANIFEST_TTL = 7 * 86400; |
| 16 |
private const MAX_MANIFEST_BYTES = 2 * 1024 * 1024; |
| 17 |
private const MAX_MANIFESTS = 8; |
| 18 |
public static function scan(string $scope, array $metadata, callable $fetch): array |
| 19 |
{ |
| 20 |
$request = $metadata['source_index'] ?? null; |
| 21 |
if (!is_array($request) || ($request['version'] ?? null) !== 1 || !array_key_exists('baseline', $request) || $request['baseline'] !== null && (!is_string($request['baseline']) || !preg_match('/^[a-f0-9]{64}$/D', $request['baseline'])) || !in_array($metadata['kind'] ?? 'incremental', ['full', 'incremental'], \true)) { |
| 22 |
throw new InvalidArgumentException(__('The source index request is invalid.', 'windpress')); |
| 23 |
} |
| 24 |
$scope = hash('sha256', serialize([1, WIND_PRESS::VERSION, get_current_blog_id(), get_current_user_id(), get_locale(), $scope, apply_filters('f!windpress/core/scanner/source_index:scope', '', $scope)])); |
| 25 |
$cursor = $metadata['next_batch'] ?? \false; |
| 26 |
$context = $metadata; |
| 27 |
unset($context['next_batch']); |
| 28 |
ksort($context); |
| 29 |
$context = hash('sha256', serialize($context)); |
| 30 |
$page = 0; |
| 31 |
$token = bin2hex(random_bytes(16)); |
| 32 |
if ($cursor !== \false && $cursor !== null) { |
| 33 |
if (!is_string($cursor) || !preg_match('/^idx_([a-f0-9]{32})_([0-9]+)$/D', $cursor, $matches)) { |
| 34 |
throw new InvalidArgumentException(__('The source index cursor is invalid.', 'windpress')); |
| 35 |
} |
| 36 |
$token = $matches[1]; |
| 37 |
$page = (int) $matches[2]; |
| 38 |
} |
| 39 |
$key = 'windpress_source_job_' . $token; |
| 40 |
$lease = \WindPress\WindPress\Core\Scanner\ScanLock::acquire($key); |
| 41 |
if ($lease === null) { |
| 42 |
throw new RuntimeException(__('This source scan is already processing. Retry the same cursor.', 'windpress'), 409); |
| 43 |
} |
| 44 |
try { |
| 45 |
if ($cursor === \false || $cursor === null) { |
| 46 |
$baseline = ($metadata['kind'] ?? 'incremental') === 'full' ? null : $request['baseline']; |
| 47 |
$manifest = $baseline === null ? \false : get_transient(self::manifest_key($scope, $baseline)); |
| 48 |
if (!is_array($manifest) || ($manifest['scope'] ?? null) !== $scope || !is_array($manifest['sources'] ?? null)) { |
| 49 |
$baseline = null; |
| 50 |
$manifest = ['sources' => []]; |
| 51 |
} |
| 52 |
$state = ['scope' => $scope, 'context' => $context, 'page' => 0, 'cursor' => \false, 'baseline' => $baseline, 'previous' => $manifest['sources'], 'current' => [], 'cursors' => []]; |
| 53 |
} else { |
| 54 |
$state = get_transient($key); |
| 55 |
if (!is_array($state) || ($state['scope'] ?? null) !== $scope || ($state['context'] ?? null) !== $context) { |
| 56 |
throw new RuntimeException(__('The source scan expired or its context changed. Restart the build.', 'windpress'), 409); |
| 57 |
} |
| 58 |
if (($state['last_page'] ?? null) === $page) { |
| 59 |
return $state['last_response']; |
| 60 |
} |
| 61 |
if (($state['page'] ?? null) !== $page || ($state['cursor'] ?? \false) === \false) { |
| 62 |
throw new RuntimeException(__('The source cursor is out of sequence. Restart the build.', 'windpress'), 409); |
| 63 |
} |
| 64 |
} |
| 65 |
$provider_metadata = $metadata; |
| 66 |
$provider_metadata['next_batch'] = $state['cursor']; |
| 67 |
unset($provider_metadata['source_index']); |
| 68 |
$result = $fetch($provider_metadata); |
| 69 |
if (!is_array($result) || !is_array($result['contents'] ?? null) || !is_array($result['metadata'] ?? null)) { |
| 70 |
throw new RuntimeException(__('The indexed provider returned an invalid response.', 'windpress')); |
| 71 |
} |
| 72 |
$contents = []; |
| 73 |
$examined = 0; |
| 74 |
foreach ($result['contents'] as $source) { |
| 75 |
if (!is_array($source) || !is_string($source['source_id'] ?? null) || $source['source_id'] === '' || strlen($source['source_id']) > 2048 || !is_string($source['content'] ?? null) || isset($source['type']) && !is_string($source['type'])) { |
| 76 |
throw new RuntimeException(__('An indexed source requires a stable ID and text content.', 'windpress')); |
| 77 |
} |
| 78 |
$id = $source['source_id']; |
| 79 |
if (isset($state['current'][$id])) { |
| 80 |
throw new RuntimeException(__('The provider returned a duplicate source ID: ', 'windpress') . $id); |
| 81 |
} |
| 82 |
$hash = hash('sha256', serialize([$source['content'], $source['type'] ?? null])); |
| 83 |
$state['current'][$id] = $hash; |
| 84 |
$examined++; |
| 85 |
if (($state['previous'][$id] ?? null) !== $hash) { |
| 86 |
$source['source_hash'] = $hash; |
| 87 |
$contents[] = $source; |
| 88 |
} |
| 89 |
} |
| 90 |
$next = $result['metadata']['next_batch'] ?? \false; |
| 91 |
if ($next !== \false && (!is_string($next) || $next === '') && (!is_int($next) || $next < 1)) { |
| 92 |
throw new RuntimeException(__('The indexed provider returned an invalid cursor.', 'windpress')); |
| 93 |
} |
| 94 |
if ($next !== \false) { |
| 95 |
$cursor_key = hash('sha256', serialize($next)); |
| 96 |
if (isset($state['cursors'][$cursor_key])) { |
| 97 |
throw new RuntimeException(__('The indexed provider repeated a cursor.', 'windpress')); |
| 98 |
} |
| 99 |
$state['cursors'][$cursor_key] = \true; |
| 100 |
} |
| 101 |
$revision = null; |
| 102 |
$deleted = []; |
| 103 |
if ($next === \false) { |
| 104 |
ksort($state['current'], \SORT_STRING); |
| 105 |
$revision = hash('sha256', serialize([$scope, $state['current']])); |
| 106 |
$deleted = array_map('strval', array_keys(array_diff_key($state['previous'], $state['current']))); |
| 107 |
} |
| 108 |
$result['contents'] = $contents; |
| 109 |
$result['metadata']['next_batch'] = $next === \false ? \false : 'idx_' . $token . '_' . ($page + 1); |
| 110 |
$result['metadata']['source_index'] = ['version' => 1, 'mode' => $state['baseline'] === null ? 'snapshot' : 'delta', 'scope' => $scope, 'base_revision' => $state['baseline'], 'revision' => $revision, 'deleted' => $deleted, 'examined' => $examined, 'changed' => count($contents), 'unchanged' => $examined - count($contents)]; |
| 111 |
if (!\WindPress\WindPress\Core\Scanner\ScanLock::is_owner($key, $lease)) { |
| 112 |
throw new RuntimeException(__('The source scan lease expired. Restart the build.', 'windpress'), 409); |
| 113 |
} |
| 114 |
if ($revision !== null) { |
| 115 |
self::save_manifest($scope, $revision, $state['current']); |
| 116 |
unset($state['previous'], $state['current'], $state['cursors']); |
| 117 |
} |
| 118 |
$state['cursor'] = $next; |
| 119 |
$state['page'] = $page + 1; |
| 120 |
$state['last_page'] = $page; |
| 121 |
$state['last_response'] = $result; |
| 122 |
// A single-page scan has no continuation to replay. |
| 123 |
if ($next !== \false || $page > 0) { |
| 124 |
self::persist($key, $state, self::JOB_TTL); |
| 125 |
} |
| 126 |
return $result; |
| 127 |
} finally { |
| 128 |
\WindPress\WindPress\Core\Scanner\ScanLock::release($key, $lease); |
| 129 |
} |
| 130 |
} |
| 131 |
private static function manifest_key(string $scope, string $revision): string |
| 132 |
{ |
| 133 |
return 'windpress_source_manifest_' . hash('sha256', $scope . $revision); |
| 134 |
} |
| 135 |
private static function save_manifest(string $scope, string $revision, array $sources): void |
| 136 |
{ |
| 137 |
$manifest = ['scope' => $scope, 'sources' => $sources]; |
| 138 |
// Oversized sites can still build; their next request falls back to a snapshot. |
| 139 |
if (strlen(serialize($manifest)) > self::MAX_MANIFEST_BYTES) { |
| 140 |
return; |
| 141 |
} |
| 142 |
$registry_key = 'windpress_source_history_' . $scope; |
| 143 |
$lease = \WindPress\WindPress\Core\Scanner\ScanLock::acquire($registry_key); |
| 144 |
if ($lease === null) { |
| 145 |
throw new RuntimeException(__('Another scan is saving its source manifest. Retry the same cursor.', 'windpress'), 409); |
| 146 |
} |
| 147 |
try { |
| 148 |
$history = get_transient($registry_key); |
| 149 |
$history = is_array($history) ? $history : []; |
| 150 |
unset($history[$revision]); |
| 151 |
$history[$revision] = \true; |
| 152 |
$expired = []; |
| 153 |
while (count($history) > self::MAX_MANIFESTS) { |
| 154 |
$expired[] = array_key_first($history); |
| 155 |
array_shift($history); |
| 156 |
} |
| 157 |
self::persist(self::manifest_key($scope, $revision), $manifest, self::MANIFEST_TTL); |
| 158 |
self::persist($registry_key, $history, self::MANIFEST_TTL); |
| 159 |
foreach ($expired as $old_revision) { |
| 160 |
delete_transient(self::manifest_key($scope, $old_revision)); |
| 161 |
} |
| 162 |
} finally { |
| 163 |
\WindPress\WindPress\Core\Scanner\ScanLock::release($registry_key, $lease); |
| 164 |
} |
| 165 |
} |
| 166 |
private static function persist(string $key, array $value, int $ttl): void |
| 167 |
{ |
| 168 |
if (!set_transient($key, $value, $ttl) && get_transient($key) !== $value) { |
| 169 |
throw new RuntimeException(__('Unable to save the source index. Restart the build.', 'windpress')); |
| 170 |
} |
| 171 |
} |
| 172 |
} |
| 173 |
|