PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
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 / includes / class-metasync-oxygen-compat.php

class-metasync-oxygen-compat.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.1, at includes/class-metasync-oxygen-compat.php

267 lines 9.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Oxygen Builder Compatibility – auto re-signs [oxygen] dynamic shortcodes
5 * when their HMAC signatures are invalid (e.g. after design-set import or migration).
6 *
7 * Runs once on `admin_init`. Skips entirely when Oxygen is inactive or signature
8 * validation is disabled. Uses a two-tier fingerprint so it only re-processes when
9 * the private key or template content actually changes.
10 *
11 * Uses WordPress shortcode_parse_atts() to correctly parse ALL shortcode attributes
12 * (data, format, size, taxonomy, separator, etc.) — matching Oxygen's own signing
13 * approach in ct_sign_oxy_dynamic_shortcode().
14 *
15 * @package MetaSync
16 */
17
18 if (!defined('ABSPATH')) {
19 exit;
20 }
21
22 class Metasync_Oxygen_Compat
23 {
24 const OPTION_KEY = 'metasync_oxygen_signatures_hash';
25
26 /**
27 * Regex: captures the full inner attribute string of any [oxygen …] shortcode.
28 * Works for both normal quotes and backslash-escaped quotes (\') used inside
29 * ct_options JSON in _ct_builder_shortcodes.
30 */
31 const OXY_PATTERN = '/\[oxygen\s+([^\]]+)\]/';
32
33 /**
34 * Entry point – hooked to admin_init.
35 */
36 public static function maybe_resign_shortcodes()
37 {
38 if (!self::should_run()) {
39 return;
40 }
41
42 // Tier 1: lightweight check — private key + template count + latest modification.
43 // Avoids loading all JSON blobs on every admin page load.
44 $light_fp = self::build_light_fingerprint();
45 $stored = get_option(self::OPTION_KEY, '');
46
47 if (!empty($stored) && strpos($stored, '|') !== false) {
48 list($stored_light) = explode('|', $stored, 2);
49 if ($stored_light === $light_fp) {
50 return; // Nothing changed since last run
51 }
52 }
53
54 // Tier 2: full check — load JSON, verify signatures, re-sign if needed.
55 $full_fp = self::build_full_fingerprint();
56 if (!empty($stored)) {
57 list(, $stored_full) = array_pad(explode('|', $stored, 2), 2, '');
58 if ($stored_full === $full_fp) {
59 // Templates changed (e.g. CSS edit) but signatures are still valid.
60 // Update tier-1 to reflect the new lightweight fingerprint.
61 update_option(self::OPTION_KEY, $light_fp . '|' . $full_fp, true);
62 return;
63 }
64 }
65
66 $updated = self::resign_templates();
67
68 if ($updated > 0) {
69 $full_fp = self::build_full_fingerprint(); // Recompute after DB writes
70 }
71
72 update_option(self::OPTION_KEY, $light_fp . '|' . $full_fp, true);
73 }
74
75 // ------------------------------------------------------------------
76 // Guards
77 // ------------------------------------------------------------------
78
79 private static function should_run()
80 {
81 if (!class_exists('OXYGEN_VSB_Signature')) {
82 return false;
83 }
84
85 $enabled = get_option('oxygen_vsb_enable_signature_validation');
86 return $enabled && $enabled !== 'false';
87 }
88
89 /**
90 * Get or create an Oxygen signature helper.
91 */
92 private static function get_signer()
93 {
94 global $oxygen_signature;
95
96 if (isset($oxygen_signature) && $oxygen_signature instanceof OXYGEN_VSB_Signature) {
97 return $oxygen_signature;
98 }
99
100 if (class_exists('OXYGEN_VSB_Signature')) {
101 return new OXYGEN_VSB_Signature();
102 }
103
104 return null;
105 }
106
107 // ------------------------------------------------------------------
108 // Fingerprints
109 // ------------------------------------------------------------------
110
111 /**
112 * Tier 1: lightweight fingerprint — private key + template count + latest modification.
113 * No JSON blobs loaded; just a single aggregate query.
114 */
115 private static function build_light_fingerprint()
116 {
117 global $wpdb;
118
119 $key = get_option('oxygen_private_key', '');
120
121 $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- reads Oxygen builder template storage — the third-party builder has no WordPress API
122 "SELECT COUNT(*) AS cnt, MAX(p.post_modified_gmt) AS latest
123 FROM {$wpdb->posts} p
124 INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = '_ct_builder_json'
125 WHERE p.post_type = 'ct_template'
126 AND p.post_status = 'publish'"
127 );
128
129 $cnt = $row ? $row->cnt : 0;
130 $latest = $row ? $row->latest : '';
131
132 return hash('sha256', $key . '|' . $cnt . '|' . $latest);
133 }
134
135 /**
136 * Tier 2: full fingerprint — SHA-256 of private key + all template JSON content.
137 * Only called when the lightweight check detects a change.
138 */
139 private static function build_full_fingerprint()
140 {
141 global $wpdb;
142
143 $key = get_option('oxygen_private_key', '');
144
145 $json_rows = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- reads Oxygen builder template storage — the third-party builder has no WordPress API
146 "SELECT pm.meta_value
147 FROM {$wpdb->postmeta} pm
148 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
149 WHERE p.post_type = 'ct_template'
150 AND p.post_status = 'publish'
151 AND pm.meta_key = '_ct_builder_json'
152 ORDER BY pm.post_id ASC"
153 );
154
155 return hash('sha256', $key . implode('|', $json_rows));
156 }
157
158 // ------------------------------------------------------------------
159 // Re-sign
160 // ------------------------------------------------------------------
161
162 private static function resign_templates()
163 {
164 global $wpdb;
165
166 $signer = self::get_signer();
167 if (!$signer) {
168 return 0;
169 }
170
171 $template_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- reads Oxygen builder template storage — the third-party builder has no WordPress API
172 "SELECT p.ID
173 FROM {$wpdb->posts} p
174 WHERE p.post_type = 'ct_template'
175 AND p.post_status = 'publish'
176 ORDER BY p.ID ASC"
177 );
178
179 if (empty($template_ids)) {
180 return 0;
181 }
182
183 $updated = 0;
184
185 foreach ($template_ids as $tpl_id) {
186 $changed = false;
187
188 // _ct_builder_json — primary rendering source
189 $json = get_post_meta($tpl_id, '_ct_builder_json', true);
190 if (!empty($json)) {
191 $new_json = self::resign_oxygen_in_string($json, $signer);
192 if ($new_json !== $json) {
193 update_post_meta($tpl_id, '_ct_builder_json', wp_slash($new_json));
194 $changed = true;
195 }
196 }
197
198 // _ct_builder_shortcodes — secondary format
199 $sc = get_post_meta($tpl_id, '_ct_builder_shortcodes', true);
200 if (!empty($sc)) {
201 $new_sc = self::resign_oxygen_in_string($sc, $signer);
202 if ($new_sc !== $sc) {
203 update_post_meta($tpl_id, '_ct_builder_shortcodes', wp_slash($new_sc));
204 $changed = true;
205 }
206 }
207
208 if ($changed) {
209 $updated++;
210 }
211 }
212
213 return $updated;
214 }
215
216 /**
217 * Find every [oxygen …] shortcode in a string and re-sign any whose
218 * signature doesn't match the current private key. Preserves ALL attributes.
219 *
220 * Handles both normal quotes: data='title'
221 * and escaped quotes: data=\'title\'
222 *
223 * @param string $content Raw meta value.
224 * @param OXYGEN_VSB_Signature $signer Oxygen's signature helper.
225 * @return string Content with corrected signatures.
226 */
227 private static function resign_oxygen_in_string($content, $signer)
228 {
229 return preg_replace_callback(self::OXY_PATTERN, function ($match) use ($signer) {
230 $inner = $match[1];
231
232 // Detect whether this shortcode uses escaped quotes (\')
233 $uses_escaped_quotes = strpos($inner, "\\'") !== false;
234
235 // Normalize escaped quotes for shortcode_parse_atts()
236 $normalized = $uses_escaped_quotes ? str_replace("\\'", "'", $inner) : $inner;
237 $attr = shortcode_parse_atts(trim($normalized));
238
239 if (!is_array($attr) || empty($attr['ct_sign_sha256'])) {
240 return $match[0]; // Not a signed oxygen shortcode, leave as-is
241 }
242
243 $stored_sig = $attr['ct_sign_sha256'];
244
245 // Build the attributes WITHOUT the signature for verification
246 $attr_without_sig = $attr;
247 unset($attr_without_sig['ct_sign_sha256']);
248
249 $expected = $signer->generate_signature('oxygen', $attr_without_sig, null);
250
251 // If signature is already valid, skip
252 if (hash_equals($expected, $stored_sig)) {
253 return $match[0];
254 }
255
256 // Rebuild the shortcode with the correct signature + all original attributes
257 $q = $uses_escaped_quotes ? "\\'" : "'";
258 $parts = "ct_sign_sha256={$q}{$expected}{$q}";
259 foreach ($attr_without_sig as $key => $val) {
260 $parts .= " {$key}={$q}{$val}{$q}";
261 }
262
263 return "[oxygen {$parts} ]";
264 }, $content);
265 }
266 }
267