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 / includes / class-metasync-oxygen-compat.php

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

275 lines 9.4 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 (function_exists('wp_doing_ajax') && wp_doing_ajax()) {
39 return;
40 }
41
42 if (!current_user_can('manage_options')) {
43 return;
44 }
45
46 if (!self::should_run()) {
47 return;
48 }
49
50 // Tier 1: lightweight check — private key + template count + latest modification.
51 // Avoids loading all JSON blobs on every admin page load.
52 $light_fp = self::build_light_fingerprint();
53 $stored = get_option(self::OPTION_KEY, '');
54
55 if (!empty($stored) && strpos($stored, '|') !== false) {
56 list($stored_light) = explode('|', $stored, 2);
57 if ($stored_light === $light_fp) {
58 return; // Nothing changed since last run
59 }
60 }
61
62 // Tier 2: full check — load JSON, verify signatures, re-sign if needed.
63 $full_fp = self::build_full_fingerprint();
64 if (!empty($stored)) {
65 list(, $stored_full) = array_pad(explode('|', $stored, 2), 2, '');
66 if ($stored_full === $full_fp) {
67 // Templates changed (e.g. CSS edit) but signatures are still valid.
68 // Update tier-1 to reflect the new lightweight fingerprint.
69 update_option(self::OPTION_KEY, $light_fp . '|' . $full_fp, true);
70 return;
71 }
72 }
73
74 $updated = self::resign_templates();
75
76 if ($updated > 0) {
77 $full_fp = self::build_full_fingerprint(); // Recompute after DB writes
78 }
79
80 update_option(self::OPTION_KEY, $light_fp . '|' . $full_fp, true);
81 }
82
83 // ------------------------------------------------------------------
84 // Guards
85 // ------------------------------------------------------------------
86
87 private static function should_run()
88 {
89 if (!class_exists('OXYGEN_VSB_Signature')) {
90 return false;
91 }
92
93 $enabled = get_option('oxygen_vsb_enable_signature_validation');
94 return $enabled && $enabled !== 'false';
95 }
96
97 /**
98 * Get or create an Oxygen signature helper.
99 */
100 private static function get_signer()
101 {
102 global $oxygen_signature;
103
104 if (isset($oxygen_signature) && $oxygen_signature instanceof OXYGEN_VSB_Signature) {
105 return $oxygen_signature;
106 }
107
108 if (class_exists('OXYGEN_VSB_Signature')) {
109 return new OXYGEN_VSB_Signature();
110 }
111
112 return null;
113 }
114
115 // ------------------------------------------------------------------
116 // Fingerprints
117 // ------------------------------------------------------------------
118
119 /**
120 * Tier 1: lightweight fingerprint — private key + template count + latest modification.
121 * No JSON blobs loaded; just a single aggregate query.
122 */
123 private static function build_light_fingerprint()
124 {
125 global $wpdb;
126
127 $key = get_option('oxygen_private_key', '');
128
129 $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- reads Oxygen builder template storage — the third-party builder has no WordPress API
130 "SELECT COUNT(*) AS cnt, MAX(p.post_modified_gmt) AS latest
131 FROM {$wpdb->posts} p
132 INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = '_ct_builder_json'
133 WHERE p.post_type = 'ct_template'
134 AND p.post_status = 'publish'"
135 );
136
137 $cnt = $row ? $row->cnt : 0;
138 $latest = $row ? $row->latest : '';
139
140 return hash('sha256', $key . '|' . $cnt . '|' . $latest);
141 }
142
143 /**
144 * Tier 2: full fingerprint — SHA-256 of private key + all template JSON content.
145 * Only called when the lightweight check detects a change.
146 */
147 private static function build_full_fingerprint()
148 {
149 global $wpdb;
150
151 $key = get_option('oxygen_private_key', '');
152
153 $json_rows = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- reads Oxygen builder template storage — the third-party builder has no WordPress API
154 "SELECT pm.meta_value
155 FROM {$wpdb->postmeta} pm
156 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
157 WHERE p.post_type = 'ct_template'
158 AND p.post_status = 'publish'
159 AND pm.meta_key = '_ct_builder_json'
160 ORDER BY pm.post_id ASC"
161 );
162
163 return hash('sha256', $key . implode('|', $json_rows));
164 }
165
166 // ------------------------------------------------------------------
167 // Re-sign
168 // ------------------------------------------------------------------
169
170 private static function resign_templates()
171 {
172 global $wpdb;
173
174 $signer = self::get_signer();
175 if (!$signer) {
176 return 0;
177 }
178
179 $template_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- reads Oxygen builder template storage — the third-party builder has no WordPress API
180 "SELECT p.ID
181 FROM {$wpdb->posts} p
182 WHERE p.post_type = 'ct_template'
183 AND p.post_status = 'publish'
184 ORDER BY p.ID ASC"
185 );
186
187 if (empty($template_ids)) {
188 return 0;
189 }
190
191 $updated = 0;
192
193 foreach ($template_ids as $tpl_id) {
194 $changed = false;
195
196 // _ct_builder_json — primary rendering source
197 $json = get_post_meta($tpl_id, '_ct_builder_json', true);
198 if (!empty($json)) {
199 $new_json = self::resign_oxygen_in_string($json, $signer);
200 if ($new_json !== $json) {
201 update_post_meta($tpl_id, '_ct_builder_json', wp_slash($new_json));
202 $changed = true;
203 }
204 }
205
206 // _ct_builder_shortcodes — secondary format
207 $sc = get_post_meta($tpl_id, '_ct_builder_shortcodes', true);
208 if (!empty($sc)) {
209 $new_sc = self::resign_oxygen_in_string($sc, $signer);
210 if ($new_sc !== $sc) {
211 update_post_meta($tpl_id, '_ct_builder_shortcodes', wp_slash($new_sc));
212 $changed = true;
213 }
214 }
215
216 if ($changed) {
217 $updated++;
218 }
219 }
220
221 return $updated;
222 }
223
224 /**
225 * Find every [oxygen …] shortcode in a string and re-sign any whose
226 * signature doesn't match the current private key. Preserves ALL attributes.
227 *
228 * Handles both normal quotes: data='title'
229 * and escaped quotes: data=\'title\'
230 *
231 * @param string $content Raw meta value.
232 * @param OXYGEN_VSB_Signature $signer Oxygen's signature helper.
233 * @return string Content with corrected signatures.
234 */
235 private static function resign_oxygen_in_string($content, $signer)
236 {
237 return preg_replace_callback(self::OXY_PATTERN, function ($match) use ($signer) {
238 $inner = $match[1];
239
240 // Detect whether this shortcode uses escaped quotes (\')
241 $uses_escaped_quotes = strpos($inner, "\\'") !== false;
242
243 // Normalize escaped quotes for shortcode_parse_atts()
244 $normalized = $uses_escaped_quotes ? str_replace("\\'", "'", $inner) : $inner;
245 $attr = shortcode_parse_atts(trim($normalized));
246
247 if (!is_array($attr) || empty($attr['ct_sign_sha256'])) {
248 return $match[0]; // Not a signed oxygen shortcode, leave as-is
249 }
250
251 $stored_sig = $attr['ct_sign_sha256'];
252
253 // Build the attributes WITHOUT the signature for verification
254 $attr_without_sig = $attr;
255 unset($attr_without_sig['ct_sign_sha256']);
256
257 $expected = $signer->generate_signature('oxygen', $attr_without_sig, null);
258
259 // If signature is already valid, skip
260 if (hash_equals($expected, $stored_sig)) {
261 return $match[0];
262 }
263
264 // Rebuild the shortcode with the correct signature + all original attributes
265 $q = $uses_escaped_quotes ? "\\'" : "'";
266 $parts = "ct_sign_sha256={$q}{$expected}{$q}";
267 foreach ($attr_without_sig as $key => $val) {
268 $parts .= " {$key}={$q}{$val}{$q}";
269 }
270
271 return "[oxygen {$parts} ]";
272 }, $content);
273 }
274 }
275