| 1 |
<?php |
| 2 |
/** |
| 3 |
* Bounded audit log for OTTO crawl-notify webhook deliveries. |
| 4 |
* |
| 5 |
* The crawl-notify webhook stores two things today, and neither answers |
| 6 |
* "what did the crawler send us, and when did it land?": the crawl data |
| 7 |
* option is a living snapshot whose URL list is merged on every delivery, |
| 8 |
* and the per-URL status store recycles terminal rows after 48 hours. This |
| 9 |
* store fills that gap: one append-only entry per webhook delivery, carrying |
| 10 |
* the WordPress receipt timestamp, the domain, and the complete URL list |
| 11 |
* exactly as delivered. |
| 12 |
* |
| 13 |
* Entries are keyed per delivery, NOT per URL and NOT derived from the |
| 14 |
* payload — the crawler may legitimately re-deliver an identical batch, and |
| 15 |
* every delivery must stay separately auditable. Each entry also carries a |
| 16 |
* reserved 'extra' map so future webhook metadata can ride along without a |
| 17 |
* storage redesign. |
| 18 |
* |
| 19 |
* Storage is option-backed and deliberately bounded, mirroring |
| 20 |
* Metasync_Otto_Job_Status: entries older than RETENTION_TTL are pruned on |
| 21 |
* every write, and the store is hard-capped at MAX_ENTRIES with oldest-first |
| 22 |
* eviction, so a chatty crawler can never grow it without limit. The option |
| 23 |
* is never autoloaded. |
| 24 |
* |
| 25 |
* As with the other option-backed stores, a write is a read-modify-write of |
| 26 |
* the whole option: two deliveries landing in the exact same instant on |
| 27 |
* parallel requests can race, with one delivery's entry surviving. The |
| 28 |
* crawler delivers per page-update, so this is vanishingly rare in practice |
| 29 |
* and the trade is the same one the status store already makes. |
| 30 |
* |
| 31 |
* The log is strictly additive to the crawl-notify pipeline: the caller wraps |
| 32 |
* every write in a failure guard, so a logging failure must never turn a |
| 33 |
* webhook delivery into an error response. |
| 34 |
* |
| 35 |
* @package Search Atlas SEO |
| 36 |
*/ |
| 37 |
|
| 38 |
if (!defined('ABSPATH')) { |
| 39 |
exit; |
| 40 |
} |
| 41 |
|
| 42 |
final class Metasync_Otto_Webhook_Audit { |
| 43 |
|
| 44 |
const OPTION = 'metasync_otto_webhook_audit_log'; |
| 45 |
|
| 46 |
/** |
| 47 |
* Retention window: 7 days (604800s). Entries older than this are pruned |
| 48 |
* on every write. The per-URL status store keeps history for only 48 |
| 49 |
* hours, so webhook receipts deliberately outlive per-URL state: a |
| 50 |
* support or compliance lookback a week later can still reach the |
| 51 |
* original payload after the working state has long since been recycled. |
| 52 |
*/ |
| 53 |
const RETENTION_TTL = 604800; |
| 54 |
|
| 55 |
/** |
| 56 |
* Hard size cap so retention alone can never grow storage without limit |
| 57 |
* (e.g. a crawler retry storm). Eviction is oldest-received-first. |
| 58 |
* Roughly a thousand deliveries of a realistic batch (a domain plus a |
| 59 |
* few dozen URLs) is a few hundred KB of option data, and the cap keeps |
| 60 |
* the worst case — a thousand full-site resync batches — from trending |
| 61 |
* toward megabytes on every read/write. |
| 62 |
*/ |
| 63 |
const MAX_ENTRIES = 1000; |
| 64 |
|
| 65 |
/** |
| 66 |
* Record one webhook delivery. |
| 67 |
* |
| 68 |
* Appends an entry keyed uniquely per delivery and prunes the store, so a |
| 69 |
* single call both records and enforces the retention/size bounds. The |
| 70 |
* caller owns the failure guard; a false return here means the delivery |
| 71 |
* was not auditable (blank domain or empty URL list), never an error. |
| 72 |
* |
| 73 |
* @param int $received_at Unix timestamp captured when WordPress |
| 74 |
* received the webhook. |
| 75 |
* @param mixed $domain Domain exactly as delivered in the payload. |
| 76 |
* @param array $urls Complete URL list exactly as delivered. |
| 77 |
* @param array $extra Optional extra fields, kept under the |
| 78 |
* reserved 'extra' key so future metadata needs |
| 79 |
* no schema change. |
| 80 |
* @return string|false The new entry's key, or false when nothing was |
| 81 |
* recorded. |
| 82 |
*/ |
| 83 |
public static function record($received_at, $domain, array $urls = [], array $extra = []) { |
| 84 |
if (!self::is_nonempty_string($domain)) { |
| 85 |
return false; |
| 86 |
} |
| 87 |
|
| 88 |
if ($urls === []) { |
| 89 |
return false; |
| 90 |
} |
| 91 |
|
| 92 |
$store = get_option(self::OPTION, []); |
| 93 |
if (!is_array($store)) { |
| 94 |
// A corrupt store is re-initialised, not propagated: the current |
| 95 |
// delivery is still auditable even if history was lost. |
| 96 |
$store = []; |
| 97 |
} |
| 98 |
|
| 99 |
$received_at = (int) $received_at; |
| 100 |
$key = self::make_key($received_at); |
| 101 |
|
| 102 |
// One slot per delivery: two processes can land deliveries in the |
| 103 |
// same second with sequences that start at the same offset, so re-key |
| 104 |
// until the slot is free rather than overwrite a stored delivery. |
| 105 |
while (isset($store[$key])) { |
| 106 |
$key = self::make_key($received_at); |
| 107 |
} |
| 108 |
|
| 109 |
$store[$key] = [ |
| 110 |
'received_at' => $received_at, |
| 111 |
'domain' => (string) $domain, |
| 112 |
'urls' => array_values($urls), |
| 113 |
'extra' => $extra, |
| 114 |
]; |
| 115 |
|
| 116 |
$store = self::prune($store); |
| 117 |
|
| 118 |
update_option(self::OPTION, $store, false); |
| 119 |
return $key; |
| 120 |
} |
| 121 |
|
| 122 |
/** |
| 123 |
* All audit entries, keyed by delivery key (empty array when none). |
| 124 |
* |
| 125 |
* Reads are side-effect free: pruning happens on write only, so a read |
| 126 |
* can never rewrite the option under a concurrent delivery. |
| 127 |
* |
| 128 |
* @return array |
| 129 |
*/ |
| 130 |
public static function all() { |
| 131 |
$store = get_option(self::OPTION, []); |
| 132 |
return is_array($store) ? $store : []; |
| 133 |
} |
| 134 |
|
| 135 |
/** |
| 136 |
* Entries received at or after $since. |
| 137 |
* |
| 138 |
* @param int $since Unix timestamp lower bound. |
| 139 |
* @return array |
| 140 |
*/ |
| 141 |
public static function since($since) { |
| 142 |
$matching = []; |
| 143 |
foreach (self::all() as $key => $entry) { |
| 144 |
if (is_array($entry) && (int) ($entry['received_at'] ?? 0) >= $since) { |
| 145 |
$matching[$key] = $entry; |
| 146 |
} |
| 147 |
} |
| 148 |
return $matching; |
| 149 |
} |
| 150 |
|
| 151 |
/** |
| 152 |
* Remove every audit entry. For tooling and uninstall only — uninstall |
| 153 |
* also sweeps the option via the plugin's metasync_% pattern. |
| 154 |
* |
| 155 |
* @return bool |
| 156 |
*/ |
| 157 |
public static function flush() { |
| 158 |
return delete_option(self::OPTION); |
| 159 |
} |
| 160 |
|
| 161 |
/** |
| 162 |
* Enforce the retention and size bounds. |
| 163 |
* |
| 164 |
* Entries past the retention cutoff are dropped first; survivors are |
| 165 |
* ordered newest-first so the array_slice() head keeps the newest |
| 166 |
* MAX_ENTRIES deliveries and the tail (oldest) is what overflow evicts. |
| 167 |
* |
| 168 |
* @param array $store |
| 169 |
* @return array |
| 170 |
*/ |
| 171 |
private static function prune($store) { |
| 172 |
$cutoff = time() - self::RETENTION_TTL; |
| 173 |
|
| 174 |
$retained = []; |
| 175 |
foreach ($store as $key => $entry) { |
| 176 |
if (is_array($entry) && (int) ($entry['received_at'] ?? 0) > $cutoff) { |
| 177 |
$retained[$key] = $entry; |
| 178 |
} |
| 179 |
} |
| 180 |
|
| 181 |
uasort($retained, function ($a, $b) { |
| 182 |
// Entries in $retained passed the is_array() filter above. |
| 183 |
$ta = (int) ($a['received_at'] ?? 0); |
| 184 |
$tb = (int) ($b['received_at'] ?? 0); |
| 185 |
return $tb <=> $ta; |
| 186 |
}); |
| 187 |
|
| 188 |
if (count($retained) > self::MAX_ENTRIES) { |
| 189 |
$retained = array_slice($retained, 0, self::MAX_ENTRIES, true); |
| 190 |
} |
| 191 |
|
| 192 |
return $retained; |
| 193 |
} |
| 194 |
|
| 195 |
/** |
| 196 |
* Monotonic per-process counter appended to every key: two deliveries in |
| 197 |
* the same second never collide within a request, and each webhook is one |
| 198 |
* request, so per-process uniqueness is all the key needs beyond the |
| 199 |
* collision re-key loop in record(). |
| 200 |
* |
| 201 |
* @var int |
| 202 |
*/ |
| 203 |
private static $sequence = 0; |
| 204 |
|
| 205 |
/** |
| 206 |
* Unique per-delivery key: receipt time plus the per-process sequence. |
| 207 |
* Deliberately not derived from the payload — a re-delivered batch must |
| 208 |
* not collide with the delivery before it. |
| 209 |
* |
| 210 |
* @param int $received_at |
| 211 |
* @return string |
| 212 |
*/ |
| 213 |
private static function make_key($received_at) { |
| 214 |
return (string) $received_at . '-' . (string) (self::$sequence++); |
| 215 |
} |
| 216 |
|
| 217 |
/** |
| 218 |
* Runtime guard for the string-only part of the public API. Webhook data |
| 219 |
* is not trusted, and the untyped parameter keeps the check from being |
| 220 |
* statically redundant (same reasoning as |
| 221 |
* Metasync_Otto_Job_Status::is_nonempty_string()). |
| 222 |
* |
| 223 |
* @param mixed $value |
| 224 |
* @return bool |
| 225 |
*/ |
| 226 |
private static function is_nonempty_string($value) { |
| 227 |
return is_string($value) && $value !== ''; |
| 228 |
} |
| 229 |
} |
| 230 |
|