class-form-access-control.php
2 months ago
class-form-captcha-handler.php
2 months ago
class-form-controller.php
2 months ago
class-form-email-config-check.php
2 months ago
class-form-email-handler.php
2 months ago
class-form-encryption.php
2 months ago
class-form-exporter.php
2 months ago
class-form-field-validator.php
2 months ago
class-form-file-handler.php
2 months ago
class-form-google-auth.php
2 months ago
class-form-integration-handler.php
2 months ago
class-form-math-parser.php
2 months ago
class-form-permissions.php
2 months ago
class-form-registry.php
2 months ago
class-form-settings.php
2 months ago
class-form-submission-cpt.php
2 months ago
class-form-submission-handler.php
2 months ago
class-form-access-control.php
452 lines
| 1 | <?php |
| 2 | |
| 3 | namespace SuperbAddons\Gutenberg\Form; |
| 4 | |
| 5 | defined('ABSPATH') || exit(); |
| 6 | |
| 7 | class FormAccessControl |
| 8 | { |
| 9 | const OPTION_ENABLED = 'superbaddons_form_access_control_enabled'; |
| 10 | |
| 11 | /** |
| 12 | * Sensitive form-level attributes that require the 'configure' capability. |
| 13 | * Everything else is considered "safe" and requires the 'edit' capability. |
| 14 | */ |
| 15 | private static $sensitive_attrs = array( |
| 16 | // Email routing |
| 17 | 'emailEnabled', |
| 18 | 'emailTo', |
| 19 | 'emailSubject', |
| 20 | 'emailReplyTo', |
| 21 | 'emailCC', |
| 22 | 'emailBCC', |
| 23 | // User confirmation |
| 24 | 'sendConfirmation', |
| 25 | 'confirmationSubject', |
| 26 | 'confirmationMessage', |
| 27 | 'confirmationEmailField', |
| 28 | // Anti-spam |
| 29 | 'captchaType', |
| 30 | 'honeypotKey', |
| 31 | // Data storage |
| 32 | 'storeEnabled', |
| 33 | 'storeSpamEnabled', |
| 34 | // Integrations |
| 35 | 'mailchimpEnabled', |
| 36 | 'mailchimpListIds', |
| 37 | 'brevoEnabled', |
| 38 | 'brevoListIds', |
| 39 | // Webhook (secret stored separately in encrypted option, not as block attribute) |
| 40 | 'webhookEnabled', |
| 41 | 'webhookUrl', |
| 42 | 'webhookMethod', |
| 43 | 'webhookHeaders', |
| 44 | // Google Sheets |
| 45 | 'googleSheetsEnabled', |
| 46 | 'googleSheetsSpreadsheetUrl', |
| 47 | 'googleSheetsSheetName', |
| 48 | // Slack |
| 49 | 'slackEnabled', |
| 50 | 'slackWebhookUrl', |
| 51 | // Redirects |
| 52 | 'redirectUrl', |
| 53 | 'successBehavior', |
| 54 | ); |
| 55 | |
| 56 | /** |
| 57 | * Block names that represent form containers. |
| 58 | */ |
| 59 | private static $form_block_names = array( |
| 60 | 'superb-addons/form', |
| 61 | 'superb-addons/multistep-form', |
| 62 | ); |
| 63 | |
| 64 | public static function Initialize() |
| 65 | { |
| 66 | add_filter('wp_insert_post_data', array(__CLASS__, 'OnInsertPostData'), 10, 2); |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * Whether form access control restrictions are enabled. |
| 71 | */ |
| 72 | public static function IsEnabled() |
| 73 | { |
| 74 | return (bool) get_option(self::OPTION_ENABLED, false); |
| 75 | } |
| 76 | |
| 77 | /** |
| 78 | * Get the list of sensitive attribute keys. |
| 79 | */ |
| 80 | public static function GetSensitiveAttrs() |
| 81 | { |
| 82 | return self::$sensitive_attrs; |
| 83 | } |
| 84 | |
| 85 | /** |
| 86 | * Filter: wp_insert_post_data — enforce form access control before content is saved. |
| 87 | * |
| 88 | * @param array $data Sanitized post data about to be inserted. |
| 89 | * @param array $postarr Raw post data array (includes ID for updates). |
| 90 | * @return array Possibly corrected $data. |
| 91 | */ |
| 92 | public static function OnInsertPostData($data, $postarr) |
| 93 | { |
| 94 | // Feature must be enabled |
| 95 | if (!self::IsEnabled()) { |
| 96 | return $data; |
| 97 | } |
| 98 | |
| 99 | // Skip autosave and revisions |
| 100 | if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) { |
| 101 | return $data; |
| 102 | } |
| 103 | $post_type = isset($data['post_type']) ? $data['post_type'] : ''; |
| 104 | if ($post_type === 'revision') { |
| 105 | return $data; |
| 106 | } |
| 107 | |
| 108 | // Only process supported post types |
| 109 | if (!in_array($post_type, FormRegistry::GetSupportedPostTypes(), true)) { |
| 110 | return $data; |
| 111 | } |
| 112 | |
| 113 | // Admins always bypass |
| 114 | if (current_user_can('manage_options')) { |
| 115 | return $data; |
| 116 | } |
| 117 | |
| 118 | $content = isset($data['post_content']) ? $data['post_content'] : ''; |
| 119 | |
| 120 | // Fast bail: no form blocks in the content |
| 121 | if (strpos($content, 'superb-addons/form') === false && strpos($content, 'superb-addons/multistep-form') === false) { |
| 122 | return $data; |
| 123 | } |
| 124 | |
| 125 | // Resolve capabilities |
| 126 | $can_edit = FormPermissions::Can('edit'); |
| 127 | $can_configure = FormPermissions::Can('configure'); |
| 128 | $can_create = FormPermissions::Can('create'); |
| 129 | |
| 130 | // If user has all capabilities, nothing to enforce |
| 131 | if ($can_edit && $can_configure && $can_create) { |
| 132 | return $data; |
| 133 | } |
| 134 | |
| 135 | // Load old content for comparison (updates only) |
| 136 | $post_id = isset($postarr['ID']) ? intval($postarr['ID']) : 0; |
| 137 | $old_blocks = array(); |
| 138 | if ($post_id > 0) { |
| 139 | $old_post = get_post($post_id); |
| 140 | if ($old_post && !empty($old_post->post_content)) { |
| 141 | $old_blocks = parse_blocks($old_post->post_content); |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | // Build lookup of old form blocks by formId for fast access |
| 146 | $old_form_blocks = self::IndexFormBlocksByFormId($old_blocks); |
| 147 | |
| 148 | // Parse and enforce new content |
| 149 | $new_blocks = parse_blocks($content); |
| 150 | $enforced = self::EnforceFormBlocks($new_blocks, $old_form_blocks, $can_edit, $can_configure, $can_create); |
| 151 | $data['post_content'] = serialize_blocks($enforced); |
| 152 | |
| 153 | return $data; |
| 154 | } |
| 155 | |
| 156 | /** |
| 157 | * Build a flat lookup of form blocks indexed by formId from a parsed block tree. |
| 158 | * |
| 159 | * @param array $blocks Parsed blocks. |
| 160 | * @return array formId => block (full block array including innerBlocks/innerContent). |
| 161 | */ |
| 162 | private static function IndexFormBlocksByFormId($blocks) |
| 163 | { |
| 164 | $index = array(); |
| 165 | foreach ($blocks as $block) { |
| 166 | if (self::IsFormBlock($block) && !empty($block['attrs']['formId'])) { |
| 167 | $index[sanitize_key($block['attrs']['formId'])] = $block; |
| 168 | } |
| 169 | if (!empty($block['innerBlocks'])) { |
| 170 | $inner_index = self::IndexFormBlocksByFormId($block['innerBlocks']); |
| 171 | foreach ($inner_index as $fid => $fblock) { |
| 172 | $index[$fid] = $fblock; |
| 173 | } |
| 174 | } |
| 175 | } |
| 176 | return $index; |
| 177 | } |
| 178 | |
| 179 | /** |
| 180 | * Check if a parsed block is one of our form container blocks. |
| 181 | */ |
| 182 | private static function IsFormBlock($block) |
| 183 | { |
| 184 | return isset($block['blockName']) && in_array($block['blockName'], self::$form_block_names, true); |
| 185 | } |
| 186 | |
| 187 | /** |
| 188 | * Recursively walk blocks, enforcing access control on form blocks. |
| 189 | * |
| 190 | * @param array $blocks Parsed new blocks. |
| 191 | * @param array $old_form_blocks Indexed old form blocks (formId => block). |
| 192 | * @param bool $can_edit User has 'edit' capability. |
| 193 | * @param bool $can_configure User has 'configure' capability. |
| 194 | * @param bool $can_create User has 'create' capability. |
| 195 | * @return array Enforced blocks. |
| 196 | */ |
| 197 | private static function EnforceFormBlocks($blocks, $old_form_blocks, $can_edit, $can_configure, $can_create) |
| 198 | { |
| 199 | $result = array(); |
| 200 | |
| 201 | foreach ($blocks as $block) { |
| 202 | if (self::IsFormBlock($block)) { |
| 203 | $form_id = isset($block['attrs']['formId']) ? sanitize_key($block['attrs']['formId']) : ''; |
| 204 | |
| 205 | // Determine if this is an existing or new form |
| 206 | $is_existing = !empty($form_id) && ( |
| 207 | isset($old_form_blocks[$form_id]) || FormRegistry::Get($form_id) !== null |
| 208 | ); |
| 209 | |
| 210 | if ($is_existing) { |
| 211 | // Existing form — enforce based on edit/configure capabilities |
| 212 | $old_block = isset($old_form_blocks[$form_id]) ? $old_form_blocks[$form_id] : null; |
| 213 | |
| 214 | // If no old block in content (e.g. form was moved from another post), |
| 215 | // try rebuilding reference from stored config |
| 216 | if ($old_block === null) { |
| 217 | $old_block = self::BuildReferenceBlockFromConfig($form_id); |
| 218 | } |
| 219 | |
| 220 | if ($old_block !== null) { |
| 221 | $block = self::EnforceExistingFormBlock($block, $old_block, $can_edit, $can_configure); |
| 222 | } |
| 223 | // If we can't find old block data at all, allow through (graceful degradation) |
| 224 | } else { |
| 225 | // New form — requires 'create' capability |
| 226 | if (!$can_create) { |
| 227 | // Strip this form block entirely |
| 228 | continue; |
| 229 | } |
| 230 | // User can create: allow the block through as-is |
| 231 | } |
| 232 | |
| 233 | $result[] = $block; |
| 234 | } else { |
| 235 | // Not a form block — recurse into inner blocks |
| 236 | if (!empty($block['innerBlocks'])) { |
| 237 | $block['innerBlocks'] = self::EnforceFormBlocks( |
| 238 | $block['innerBlocks'], |
| 239 | $old_form_blocks, |
| 240 | $can_edit, |
| 241 | $can_configure, |
| 242 | $can_create |
| 243 | ); |
| 244 | } |
| 245 | $result[] = $block; |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | return $result; |
| 250 | } |
| 251 | |
| 252 | /** |
| 253 | * Enforce access control on an existing form block. |
| 254 | * |
| 255 | * @param array $new_block The incoming (new) form block. |
| 256 | * @param array $old_block The previous (old) form block. |
| 257 | * @param bool $can_edit User has 'edit' capability. |
| 258 | * @param bool $can_configure User has 'configure' capability. |
| 259 | * @return array The enforced block. |
| 260 | */ |
| 261 | private static function EnforceExistingFormBlock($new_block, $old_block, $can_edit, $can_configure) |
| 262 | { |
| 263 | if (!$can_edit && !$can_configure) { |
| 264 | // Fully read-only: restore entire block from old content |
| 265 | return $old_block; |
| 266 | } |
| 267 | |
| 268 | $new_attrs = isset($new_block['attrs']) ? $new_block['attrs'] : array(); |
| 269 | $old_attrs = isset($old_block['attrs']) ? $old_block['attrs'] : array(); |
| 270 | |
| 271 | if ($can_edit && !$can_configure) { |
| 272 | // Can edit safe attrs + inner blocks, but sensitive attrs must be restored from old |
| 273 | $new_block['attrs'] = self::RestoreSensitiveAttrs($new_attrs, $old_attrs); |
| 274 | // Protect the 'sensitive' attribute on inner form-field blocks |
| 275 | $sensitive_map = self::BuildFieldSensitiveMap($old_block); |
| 276 | if (!empty($sensitive_map)) { |
| 277 | $new_inner = isset($new_block['innerBlocks']) ? $new_block['innerBlocks'] : array(); |
| 278 | $new_block['innerBlocks'] = self::EnforceFieldSensitiveAttr($new_inner, $sensitive_map); |
| 279 | } |
| 280 | } elseif ($can_configure && !$can_edit) { |
| 281 | // Can change sensitive attrs, but safe attrs + inner blocks must be restored from old |
| 282 | $new_block['attrs'] = self::RestoreSafeAttrs($new_attrs, $old_attrs); |
| 283 | // Restore inner blocks (field structure) |
| 284 | $new_block['innerBlocks'] = isset($old_block['innerBlocks']) ? $old_block['innerBlocks'] : array(); |
| 285 | $new_block['innerContent'] = isset($old_block['innerContent']) ? $old_block['innerContent'] : array(); |
| 286 | $new_block['innerHTML'] = isset($old_block['innerHTML']) ? $old_block['innerHTML'] : ''; |
| 287 | } |
| 288 | // If both can_edit and can_configure: allow everything (shouldn't reach here due to early bail) |
| 289 | |
| 290 | // When user has 'edit' but not 'configure', inner blocks can change freely except |
| 291 | // the 'sensitive' attribute on form-field blocks (protected above via BuildFieldSensitiveMap). |
| 292 | // When user lacks 'edit', inner blocks are restored above. |
| 293 | |
| 294 | return $new_block; |
| 295 | } |
| 296 | |
| 297 | /** |
| 298 | * Restore sensitive attrs from old block, keeping safe attrs from new block. |
| 299 | * |
| 300 | * @param array $new_attrs Incoming attributes. |
| 301 | * @param array $old_attrs Previous attributes. |
| 302 | * @return array Merged attributes. |
| 303 | */ |
| 304 | private static function RestoreSensitiveAttrs($new_attrs, $old_attrs) |
| 305 | { |
| 306 | foreach (self::$sensitive_attrs as $key) { |
| 307 | if (array_key_exists($key, $old_attrs)) { |
| 308 | $new_attrs[$key] = $old_attrs[$key]; |
| 309 | } elseif (array_key_exists($key, $new_attrs)) { |
| 310 | // Sensitive attr was added that didn't exist before — remove it |
| 311 | unset($new_attrs[$key]); |
| 312 | } |
| 313 | } |
| 314 | return $new_attrs; |
| 315 | } |
| 316 | |
| 317 | /** |
| 318 | * Restore safe (non-sensitive) attrs from old block, keeping sensitive attrs from new block. |
| 319 | * |
| 320 | * @param array $new_attrs Incoming attributes. |
| 321 | * @param array $old_attrs Previous attributes. |
| 322 | * @return array Merged attributes. |
| 323 | */ |
| 324 | private static function RestoreSafeAttrs($new_attrs, $old_attrs) |
| 325 | { |
| 326 | // Start with old attrs (all safe attrs preserved), then overlay sensitive attrs from new |
| 327 | $merged = $old_attrs; |
| 328 | foreach (self::$sensitive_attrs as $key) { |
| 329 | if (array_key_exists($key, $new_attrs)) { |
| 330 | $merged[$key] = $new_attrs[$key]; |
| 331 | } elseif (isset($merged[$key])) { |
| 332 | // If new doesn't have it but old does, keep old (no removal of sensitive attrs) |
| 333 | } |
| 334 | } |
| 335 | return $merged; |
| 336 | } |
| 337 | |
| 338 | /** |
| 339 | * Build a flat map of fieldId => sensitive value from a form block's inner blocks. |
| 340 | * Walks recursively to handle both direct form-field children (form block) |
| 341 | * and form-field blocks nested inside form-step blocks (multistep-form block). |
| 342 | * |
| 343 | * Falls back to the formFields config array when innerBlocks is empty |
| 344 | * (e.g. when old_block comes from BuildReferenceBlockFromConfig). |
| 345 | * |
| 346 | * @param array $block The old form block. |
| 347 | * @return array fieldId => bool (sensitive value). |
| 348 | */ |
| 349 | private static function BuildFieldSensitiveMap($block) |
| 350 | { |
| 351 | $map = array(); |
| 352 | |
| 353 | // Primary path: walk innerBlocks recursively |
| 354 | if (!empty($block['innerBlocks'])) { |
| 355 | self::CollectFieldSensitiveValues($block['innerBlocks'], $map); |
| 356 | return $map; |
| 357 | } |
| 358 | |
| 359 | // Fallback: use formFields from stored config (flat array of field attrs) |
| 360 | $attrs = isset($block['attrs']) ? $block['attrs'] : array(); |
| 361 | if (!empty($attrs['formFields']) && is_array($attrs['formFields'])) { |
| 362 | foreach ($attrs['formFields'] as $field_attrs) { |
| 363 | if (!empty($field_attrs['fieldId'])) { |
| 364 | $map[$field_attrs['fieldId']] = !empty($field_attrs['sensitive']); |
| 365 | } |
| 366 | } |
| 367 | } |
| 368 | |
| 369 | return $map; |
| 370 | } |
| 371 | |
| 372 | /** |
| 373 | * Recursively collect fieldId => sensitive values from a list of inner blocks. |
| 374 | * |
| 375 | * @param array $blocks Inner blocks to walk. |
| 376 | * @param array &$map Map to populate (fieldId => bool). |
| 377 | */ |
| 378 | private static function CollectFieldSensitiveValues($blocks, &$map) |
| 379 | { |
| 380 | foreach ($blocks as $block) { |
| 381 | if ( |
| 382 | isset($block['blockName']) && $block['blockName'] === 'superb-addons/form-field' |
| 383 | && !empty($block['attrs']['fieldId']) |
| 384 | ) { |
| 385 | $map[$block['attrs']['fieldId']] = !empty($block['attrs']['sensitive']); |
| 386 | } |
| 387 | // Recurse into form-step or any other container |
| 388 | if (!empty($block['innerBlocks'])) { |
| 389 | self::CollectFieldSensitiveValues($block['innerBlocks'], $map); |
| 390 | } |
| 391 | } |
| 392 | } |
| 393 | |
| 394 | /** |
| 395 | * Recursively walk inner blocks and restore the 'sensitive' attribute on |
| 396 | * form-field blocks from the old sensitive map. New fields (no match in map) |
| 397 | * pass through as-is. |
| 398 | * |
| 399 | * @param array $blocks New inner blocks. |
| 400 | * @param array $sensitive_map Old fieldId => bool map. |
| 401 | * @return array Enforced inner blocks. |
| 402 | */ |
| 403 | private static function EnforceFieldSensitiveAttr($blocks, $sensitive_map) |
| 404 | { |
| 405 | $result = array(); |
| 406 | foreach ($blocks as $block) { |
| 407 | if ( |
| 408 | isset($block['blockName']) && $block['blockName'] === 'superb-addons/form-field' |
| 409 | && !empty($block['attrs']['fieldId']) |
| 410 | ) { |
| 411 | $field_id = $block['attrs']['fieldId']; |
| 412 | if (isset($sensitive_map[$field_id])) { |
| 413 | $block['attrs']['sensitive'] = $sensitive_map[$field_id]; |
| 414 | } |
| 415 | // New fields (not in map) pass through unchanged |
| 416 | } |
| 417 | // Recurse into form-step or any other container |
| 418 | if (!empty($block['innerBlocks'])) { |
| 419 | $block['innerBlocks'] = self::EnforceFieldSensitiveAttr($block['innerBlocks'], $sensitive_map); |
| 420 | } |
| 421 | $result[] = $block; |
| 422 | } |
| 423 | return $result; |
| 424 | } |
| 425 | |
| 426 | /** |
| 427 | * Build a minimal reference block from stored config when old post content isn't available. |
| 428 | * This handles cases where a form exists in the registry but the block was moved from another post. |
| 429 | * |
| 430 | * @param string $form_id The form ID. |
| 431 | * @return array|null A block-like array with attrs, or null if config not found. |
| 432 | */ |
| 433 | private static function BuildReferenceBlockFromConfig($form_id) |
| 434 | { |
| 435 | $config = FormRegistry::GetConfig($form_id); |
| 436 | if (empty($config) || !is_array($config)) { |
| 437 | return null; |
| 438 | } |
| 439 | |
| 440 | // We can restore attrs from config, but innerBlocks/innerContent cannot be |
| 441 | // faithfully reconstructed from the flat config. Return a minimal structure |
| 442 | // that allows attribute enforcement. |
| 443 | return array( |
| 444 | 'blockName' => FormRegistry::BLOCK_NAME, |
| 445 | 'attrs' => $config, |
| 446 | 'innerBlocks' => array(), |
| 447 | 'innerContent' => array(), |
| 448 | 'innerHTML' => '', |
| 449 | ); |
| 450 | } |
| 451 | } |
| 452 |