PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / otto / class-metasync-otto-webhook-audit.php

class-metasync-otto-webhook-audit.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at otto/class-metasync-otto-webhook-audit.php

230 lines 8.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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