Changeset 3475199
- Timestamp:
- 03/05/2026 07:12:52 AM (6 days ago)
- Location:
- griffinforms-form-builder/trunk
- Files:
-
- 1 added
- 16 edited
-
admin/html/pages/settings/capabilitymatrix.php (modified) (5 diffs)
-
admin/html/pages/single/submission.php (modified) (2 diffs)
-
admin/js/local/capabilitymatrix.php (modified) (3 diffs)
-
admin/language/capabilitymatrix.php (modified) (2 diffs)
-
admin/language/pagetitles.php (modified) (1 diff)
-
admin/language/settings.php (modified) (1 diff)
-
admin/secure/capabilitymatrix.php (modified) (1 diff)
-
config.php (modified) (1 diff)
-
db.php (modified) (2 diffs)
-
editors/gutenberg/registrar.php (modified) (1 diff)
-
griffinforms.php (modified) (2 diffs)
-
includes/api/submissionsrest.php (modified) (29 diffs)
-
includes/api/webhookdelivery.php (added)
-
includes/pipelines/jobworker.php (modified) (2 diffs)
-
includes/security/capabilities.php (modified) (11 diffs)
-
readme.txt (modified) (3 diffs)
-
settings.php (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
griffinforms-form-builder/trunk/admin/html/pages/settings/capabilitymatrix.php
r3474375 r3475199 4 4 5 5 use GriffinForms\Includes\Security\Capabilities; 6 use GriffinForms\Includes\Api\WebhookDelivery; 6 7 7 8 class CapabilityMatrix extends \GriffinForms\Admin\Html\Pages\Settings\Format … … 30 31 $this->getOptionHtml('api_feature_enabled'); 31 32 $this->getOptionHtml('api_kill_switch'); 33 $this->getOptionHtml('api_webhook_submission_created_enabled'); 34 $this->getOptionHtml('api_webhook_submission_created_url'); 35 $this->getOptionHtml('api_webhook_submission_created_secret'); 36 $this->getOptionHtml('api_webhook_submission_created_timeout'); 32 37 $this->getOptionHtml('api_role_capability_map'); 33 38 $this->getOptionHtml('api_user_capability_overrides'); … … 54 59 } 55 60 61 protected function getApiWebhookSubmissionCreatedEnabledHtml($input_id) 62 { 63 $value = (int) $this->settings->getOption(WebhookDelivery::OPTION_WEBHOOK_ENABLED, 0); 64 echo '<label for="' . esc_attr($input_id) . '">'; 65 echo '<input type="checkbox" id="' . esc_attr($input_id) . '" name="api_webhook_submission_created_enabled" value="1" ' . checked(1, $value, false) . ' /> '; 66 echo esc_html($this->lang->getText('api_webhook_submission_created_enabled', 'label')); 67 echo '</label>'; 68 $this->getOptionDescription('api_webhook_submission_created_enabled'); 69 } 70 71 protected function getApiWebhookSubmissionCreatedUrlHtml($input_id) 72 { 73 $value = (string) $this->settings->getOption(WebhookDelivery::OPTION_WEBHOOK_URL, ''); 74 echo '<input type="url" id="' . esc_attr($input_id) . '" name="api_webhook_submission_created_url" class="regular-text code" value="' . esc_attr($value) . '" placeholder="https://example.com/webhooks/griffinforms" />'; 75 $this->getOptionDescription('api_webhook_submission_created_url'); 76 } 77 78 protected function getApiWebhookSubmissionCreatedSecretHtml($input_id) 79 { 80 $value = (string) $this->settings->getOption(WebhookDelivery::OPTION_WEBHOOK_SECRET, ''); 81 echo '<input type="password" id="' . esc_attr($input_id) . '" name="api_webhook_submission_created_secret" class="regular-text code" value="' . esc_attr($value) . '" autocomplete="off" spellcheck="false" />'; 82 $this->getOptionDescription('api_webhook_submission_created_secret'); 83 } 84 85 protected function getApiWebhookSubmissionCreatedTimeoutHtml($input_id) 86 { 87 $value = (int) $this->settings->getOption(WebhookDelivery::OPTION_WEBHOOK_TIMEOUT, 10); 88 if ($value <= 0) { 89 $value = 10; 90 } 91 echo '<input type="number" id="' . esc_attr($input_id) . '" name="api_webhook_submission_created_timeout" class="small-text" min="3" max="30" step="1" value="' . esc_attr((string) $value) . '" />'; 92 $this->getOptionDescription('api_webhook_submission_created_timeout'); 93 } 94 56 95 protected function getApiRoleCapabilityMapHtml($input_id) 57 96 { … … 59 98 $role_names = is_object($roles) ? $roles->get_names() : []; 60 99 61 $cap_labels = [ 62 Capabilities::CAP_VIEW_SUBMISSIONS => __('View submissions', 'griffinforms-form-builder'), 63 Capabilities::CAP_VIEW_SUBMISSION_DETAIL => __('View submission detail', 'griffinforms-form-builder'), 64 Capabilities::CAP_EXPORT_SUBMISSION_PDF => __('Export submission PDF', 'griffinforms-form-builder'), 65 Capabilities::CAP_SHARE_SUBMISSION_LINK => __('Share submission link', 'griffinforms-form-builder'), 66 Capabilities::CAP_DELETE_SUBMISSION => __('Delete submission', 'griffinforms-form-builder'), 67 Capabilities::CAP_BULK_DELETE_SUBMISSIONS => __('Bulk delete submissions', 'griffinforms-form-builder'), 100 $submission_caps = [ 101 Capabilities::CAP_VIEW_SUBMISSIONS => __('View', 'griffinforms-form-builder'), 102 Capabilities::CAP_VIEW_SUBMISSION_DETAIL => __('View detail', 'griffinforms-form-builder'), 103 Capabilities::CAP_EXPORT_SUBMISSION_PDF => __('Export PDF', 'griffinforms-form-builder'), 104 Capabilities::CAP_SHARE_SUBMISSION_LINK => __('Share link', 'griffinforms-form-builder'), 105 Capabilities::CAP_DELETE_SUBMISSION => __('Delete', 'griffinforms-form-builder'), 106 Capabilities::CAP_BULK_DELETE_SUBMISSIONS => __('Bulk delete', 'griffinforms-form-builder'), 107 Capabilities::CAP_MANAGE_SUBMISSION_READ_STATE => __('Manage read state', 'griffinforms-form-builder'), 108 ]; 109 110 $folder_caps = [ 111 Capabilities::CAP_CREATE_FOLDER => __('Create', 'griffinforms-form-builder'), 112 Capabilities::CAP_RENAME_FOLDER => __('Rename', 'griffinforms-form-builder'), 113 ]; 114 115 $form_caps = [ 116 Capabilities::CAP_MOVE_FORM_FOLDER => __('Move to folder', 'griffinforms-form-builder'), 117 Capabilities::CAP_RENAME_FORM => __('Rename', 'griffinforms-form-builder'), 68 118 ]; 69 119 70 120 echo '<div id="' . esc_attr($input_id) . '" class="gf-capability-matrix-wrap">'; 71 echo '<table class="widefat striped" role="presentation">'; 121 $this->renderCapabilityTable( 122 __('Submissions Capabilities', 'griffinforms-form-builder'), 123 $submission_caps, 124 $role_names 125 ); 126 $this->renderCapabilityTable( 127 __('Folders Capabilities', 'griffinforms-form-builder'), 128 $folder_caps, 129 $role_names 130 ); 131 $this->renderCapabilityTable( 132 __('Forms Capabilities', 'griffinforms-form-builder'), 133 $form_caps, 134 $role_names 135 ); 136 echo '</div>'; 137 $this->getOptionDescription('api_role_capability_map'); 138 echo '<p><em>' . esc_html($this->lang->getText('role_assignment_saved_note')) . '</em></p>'; 139 } 140 141 protected function renderCapabilityTable(string $section_title, array $cap_labels, array $role_names): void 142 { 143 if (empty($cap_labels)) { 144 return; 145 } 146 147 echo '<h3>' . esc_html($section_title) . '</h3>'; 148 echo '<table class="widefat striped" role="presentation" style="margin-bottom:16px;">'; 72 149 echo '<thead><tr><th style="padding-left: 12px;">' . esc_html__('Role', 'griffinforms-form-builder') . '</th>'; 73 150 foreach ($cap_labels as $label) { … … 91 168 92 169 echo '</tbody></table>'; 93 echo '</div>';94 $this->getOptionDescription('api_role_capability_map');95 echo '<p><em>' . esc_html($this->lang->getText('role_assignment_saved_note')) . '</em></p>';96 170 } 97 171 -
griffinforms-form-builder/trunk/admin/html/pages/single/submission.php
r3455761 r3475199 252 252 } 253 253 254 $this->markSubmissionAsRead(); 255 254 256 $this->setCart(); 255 257 $this->setPaymentMeta(); … … 327 329 echo '</div>'; 328 330 echo '</div>'; 331 } 332 333 /** 334 * Mark current submission as read when admin opens submission detail page. 335 */ 336 private function markSubmissionAsRead(): void 337 { 338 global $wpdb; 339 340 $submission_id = absint($this->item_id); 341 if ($submission_id <= 0) { 342 return; 343 } 344 345 $table = $this->config->getTable('submission'); 346 $wpdb->query( 347 $wpdb->prepare( 348 "UPDATE %i SET is_read = 1 WHERE id = %d AND is_read <> 1", 349 $table, 350 $submission_id 351 ) 352 ); 329 353 } 330 354 -
griffinforms-form-builder/trunk/admin/js/local/capabilitymatrix.php
r3474296 r3475199 10 10 ' getApiFeatureEnabledOption();' . PHP_EOL . 11 11 ' getApiKillSwitchOption();' . PHP_EOL . 12 ' getApiWebhookSubmissionCreatedEnabledOption();' . PHP_EOL . 13 ' getApiWebhookSubmissionCreatedUrlOption();' . PHP_EOL . 14 ' getApiWebhookSubmissionCreatedSecretOption();' . PHP_EOL . 15 ' getApiWebhookSubmissionCreatedTimeoutOption();' . PHP_EOL . 12 16 ' getApiRoleCapabilityMapOption();' . PHP_EOL . 13 17 ' getApiUserCapabilityOverridesOption();' . PHP_EOL . … … 16 20 $js .= $this->getApiFeatureEnabledOptionJs(); 17 21 $js .= $this->getApiKillSwitchOptionJs(); 22 $js .= $this->getApiWebhookSubmissionCreatedEnabledOptionJs(); 23 $js .= $this->getApiWebhookSubmissionCreatedUrlOptionJs(); 24 $js .= $this->getApiWebhookSubmissionCreatedSecretOptionJs(); 25 $js .= $this->getApiWebhookSubmissionCreatedTimeoutOptionJs(); 18 26 $js .= $this->getApiRoleCapabilityMapOptionJs(); 19 27 $js .= $this->getApiUserCapabilityOverridesOptionJs(); … … 33 41 return 'function getApiKillSwitchOption() {' . PHP_EOL . 34 42 ' optionsData["api_kill_switch"] = jQuery("#griffinforms-settings-capabilitymatrix-apikillswitch").prop("checked");' . PHP_EOL . 43 '}' . PHP_EOL; 44 } 45 46 protected function getApiWebhookSubmissionCreatedEnabledOptionJs(): string 47 { 48 return 'function getApiWebhookSubmissionCreatedEnabledOption() {' . PHP_EOL . 49 ' optionsData["api_webhook_submission_created_enabled"] = jQuery("#griffinforms-settings-capabilitymatrix-apiwebhooksubmissioncreatedenabled").prop("checked");' . PHP_EOL . 50 '}' . PHP_EOL; 51 } 52 53 protected function getApiWebhookSubmissionCreatedUrlOptionJs(): string 54 { 55 return 'function getApiWebhookSubmissionCreatedUrlOption() {' . PHP_EOL . 56 ' optionsData["api_webhook_submission_created_url"] = jQuery("#griffinforms-settings-capabilitymatrix-apiwebhooksubmissioncreatedurl").val();' . PHP_EOL . 57 '}' . PHP_EOL; 58 } 59 60 protected function getApiWebhookSubmissionCreatedSecretOptionJs(): string 61 { 62 return 'function getApiWebhookSubmissionCreatedSecretOption() {' . PHP_EOL . 63 ' optionsData["api_webhook_submission_created_secret"] = jQuery("#griffinforms-settings-capabilitymatrix-apiwebhooksubmissioncreatedsecret").val();' . PHP_EOL . 64 '}' . PHP_EOL; 65 } 66 67 protected function getApiWebhookSubmissionCreatedTimeoutOptionJs(): string 68 { 69 return 'function getApiWebhookSubmissionCreatedTimeoutOption() {' . PHP_EOL . 70 ' optionsData["api_webhook_submission_created_timeout"] = jQuery("#griffinforms-settings-capabilitymatrix-apiwebhooksubmissioncreatedtimeout").val();' . PHP_EOL . 35 71 '}' . PHP_EOL; 36 72 } -
griffinforms-form-builder/trunk/admin/language/capabilitymatrix.php
r3474296 r3475199 9 9 protected function capabilitymatrixTitle() 10 10 { 11 return __(' Capability Matrix', 'griffinforms-form-builder');11 return __('API Access & Webhooks', 'griffinforms-form-builder'); 12 12 } 13 13 … … 30 30 { 31 31 return __('Immediately disables app-facing API endpoints without full rollback.', 'griffinforms-form-builder'); 32 } 33 34 protected function apiWebhookSubmissionCreatedEnabledLabel() 35 { 36 return __('Enable Submission Created Webhook', 'griffinforms-form-builder'); 37 } 38 39 protected function apiWebhookSubmissionCreatedEnabledDescription() 40 { 41 return __('Queues signed outbound webhook deliveries for new submissions.', 'griffinforms-form-builder'); 42 } 43 44 protected function apiWebhookSubmissionCreatedUrlLabel() 45 { 46 return __('Submission Webhook URL', 'griffinforms-form-builder'); 47 } 48 49 protected function apiWebhookSubmissionCreatedUrlDescription() 50 { 51 return __('HTTPS endpoint to receive submission.created events.', 'griffinforms-form-builder'); 52 } 53 54 protected function apiWebhookSubmissionCreatedSecretLabel() 55 { 56 return __('Submission Webhook Secret', 'griffinforms-form-builder'); 57 } 58 59 protected function apiWebhookSubmissionCreatedSecretDescription() 60 { 61 return __('Shared secret used to sign outbound webhook payloads (HMAC SHA-256).', 'griffinforms-form-builder'); 62 } 63 64 protected function apiWebhookSubmissionCreatedTimeoutLabel() 65 { 66 return __('Submission Webhook Timeout (seconds)', 'griffinforms-form-builder'); 67 } 68 69 protected function apiWebhookSubmissionCreatedTimeoutDescription() 70 { 71 return __('HTTP timeout for webhook delivery attempts (3-30 seconds).', 'griffinforms-form-builder'); 32 72 } 33 73 -
griffinforms-form-builder/trunk/admin/language/pagetitles.php
r3474296 r3475199 67 67 protected function capabilityMatrixTitle() 68 68 { 69 return __(' Capability Matrix', 'griffinforms-form-builder');69 return __('API Access & Webhooks', 'griffinforms-form-builder'); 70 70 } 71 71 } -
griffinforms-form-builder/trunk/admin/language/settings.php
r3474296 r3475199 44 44 protected function capabilitymatrixTitle() 45 45 { 46 return __(' Capability Matrix', 'griffinforms-form-builder');46 return __('API Access & Webhooks', 'griffinforms-form-builder'); 47 47 } 48 48 -
griffinforms-form-builder/trunk/admin/secure/capabilitymatrix.php
r3474296 r3475199 36 36 return Capabilities::normalizeUserOverrides($value); 37 37 } 38 39 protected function secureApiWebhookSubmissionCreatedEnabled($value): int 40 { 41 return !empty($value) ? 1 : 0; 42 } 43 44 protected function secureApiWebhookSubmissionCreatedUrl($value): string 45 { 46 $url = esc_url_raw((string) $value); 47 if ($url === '') { 48 return ''; 49 } 50 51 if (!wp_http_validate_url($url)) { 52 $this->errors['api_webhook_submission_created_url'] = __('Invalid webhook URL.', 'griffinforms-form-builder'); 53 return ''; 54 } 55 56 return $url; 57 } 58 59 protected function secureApiWebhookSubmissionCreatedSecret($value): string 60 { 61 return sanitize_text_field((string) $value); 62 } 63 64 protected function secureApiWebhookSubmissionCreatedTimeout($value): int 65 { 66 $timeout = absint($value); 67 if ($timeout < 3) { 68 $timeout = 3; 69 } 70 if ($timeout > 30) { 71 $timeout = 30; 72 } 73 74 return $timeout; 75 } 38 76 } -
griffinforms-form-builder/trunk/config.php
r3474296 r3475199 5 5 class Config 6 6 { 7 public const VERSION = '2.3. 5.0';8 public const DB_VER = '2.3. 5.0';7 public const VERSION = '2.3.6.0'; 8 public const DB_VER = '2.3.6.0'; 9 9 public const PHP_REQUIRED = '8.2'; 10 10 public const WP_REQUIRED = '6.2'; -
griffinforms-form-builder/trunk/db.php
r3455761 r3475199 202 202 form_id mediumint(9), 203 203 status tinyint(1) NOT NULL DEFAULT 0, 204 is_read tinyint(1) NOT NULL DEFAULT 0, 204 205 is_payment_form tinyint(1) NOT NULL DEFAULT 0, 205 206 payment_status varchar(20) NOT NULL DEFAULT 'pending', … … 715 716 } 716 717 718 $is_read_exists = $wpdb->get_results($wpdb->prepare( 719 "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = %s AND COLUMN_NAME = 'is_read'", 720 $table_name 721 )); 722 723 if (empty($is_read_exists)) { 724 $wpdb->query($wpdb->prepare( 725 "ALTER TABLE %i ADD COLUMN is_read tinyint(1) NOT NULL DEFAULT 0 AFTER status", 726 $table_name 727 )); 728 } 729 717 730 $payment_meta_exists = $wpdb->get_results($wpdb->prepare( 718 731 "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = %s AND COLUMN_NAME = 'payment_meta'", -
griffinforms-form-builder/trunk/editors/gutenberg/registrar.php
r3439965 r3475199 14 14 { 15 15 Rest::registerRoutes(); 16 static::registerBlockType(); 17 } 16 18 17 add_action('init', function () { 18 // Resolve block.json from plugin root. 19 $block_json = plugin_dir_path(__DIR__ . '/../../griffinforms.php') . 'blocks/gutenberg/block.json'; 19 /** 20 * Register Gutenberg block definition from block.json. 21 */ 22 private static function registerBlockType(): void 23 { 24 if (!function_exists('register_block_type')) { 25 return; 26 } 20 27 21 if (!file_exists($block_json)) {22 return;23 }28 // `editors/gutenberg` -> plugin root 29 $plugin_root = dirname(__DIR__, 2); 30 $block_json = $plugin_root . '/blocks/gutenberg/block.json'; 24 31 25 register_block_type( 26 $block_json, 27 array( 28 'render_callback' => array(static::class, 'renderBlock'), 29 ) 30 ); 31 }, 15); 32 if (!file_exists($block_json)) { 33 return; 34 } 35 36 register_block_type( 37 $block_json, 38 array( 39 'render_callback' => array(static::class, 'renderBlock'), 40 ) 41 ); 32 42 } 33 43 -
griffinforms-form-builder/trunk/griffinforms.php
r3474296 r3475199 4 4 * Plugin URI: https://griffinforms.com/ 5 5 * Description: A powerful and flexible form builder for WordPress. Create multi-page forms with drag-and-drop ease, custom validations, and full submission management. 6 * Version: 2.3. 5.06 * Version: 2.3.6.0 7 7 * Requires at least: 6.6 8 8 * Requires PHP: 8.2 … … 116 116 Includes\Security\Capabilities::registerHooks(); 117 117 new Includes\Api\SubmissionsRest(); 118 Includes\Api\WebhookDelivery::registerHooks(); 118 119 119 120 add_action('plugins_loaded', function () { -
griffinforms-form-builder/trunk/includes/api/submissionsrest.php
r3474296 r3475199 7 7 use GriffinForms\Settings; 8 8 use GriffinForms\Includes\Security\Capabilities; 9 use GriffinForms\Admin\Sql\App as AdminAppSql; 9 10 use GriffinForms\Admin\Sql\Format as AdminSqlFormat; 10 11 use GriffinForms\Admin\Sql\Submissions as AdminSubmissionsSql; … … 44 45 ]); 45 46 47 register_rest_route(self::NAMESPACE, '/folders/(?P<folder_id>\d+)/icon/binary', [ 48 'methods' => WP_REST_Server::READABLE, 49 'callback' => [$this, 'getFolderIconBinary'], 50 'permission_callback' => [$this, 'permissionViewSubmissions'], 51 ]); 52 53 register_rest_route(self::NAMESPACE, '/folders/rename', [ 54 'methods' => WP_REST_Server::CREATABLE, 55 'callback' => [$this, 'renameFolder'], 56 'permission_callback' => [$this, 'permissionRenameFolder'], 57 ]); 58 59 register_rest_route(self::NAMESPACE, '/folders/create', [ 60 'methods' => WP_REST_Server::CREATABLE, 61 'callback' => [$this, 'createFolder'], 62 'permission_callback' => [$this, 'permissionCreateFolder'], 63 ]); 64 46 65 register_rest_route(self::NAMESPACE, '/forms', [ 47 66 'methods' => WP_REST_Server::READABLE, … … 50 69 ]); 51 70 71 register_rest_route(self::NAMESPACE, '/forms/(?P<form_id>\d+)/structure', [ 72 'methods' => WP_REST_Server::READABLE, 73 'callback' => [$this, 'getFormStructure'], 74 'permission_callback' => [$this, 'permissionViewSubmissions'], 75 ]); 76 77 register_rest_route(self::NAMESPACE, '/forms/move-folder', [ 78 'methods' => WP_REST_Server::CREATABLE, 79 'callback' => [$this, 'moveFormFolder'], 80 'permission_callback' => [$this, 'permissionMoveFormFolder'], 81 ]); 82 83 register_rest_route(self::NAMESPACE, '/forms/rename', [ 84 'methods' => WP_REST_Server::CREATABLE, 85 'callback' => [$this, 'renameForm'], 86 'permission_callback' => [$this, 'permissionRenameForm'], 87 ]); 88 52 89 register_rest_route(self::NAMESPACE, '/submissions', [ 53 90 'methods' => WP_REST_Server::READABLE, … … 68 105 ]); 69 106 107 register_rest_route(self::NAMESPACE, '/logs/recent', [ 108 'methods' => WP_REST_Server::READABLE, 109 'callback' => [$this, 'getRecentLogs'], 110 'permission_callback' => [$this, 'permissionViewSubmissionDetail'], 111 ]); 112 70 113 register_rest_route(self::NAMESPACE, '/submissions/(?P<submission_id>\d+)/pdf', [ 71 114 'methods' => WP_REST_Server::READABLE, … … 74 117 ]); 75 118 119 register_rest_route(self::NAMESPACE, '/submissions/(?P<submission_id>\d+)/attachments', [ 120 'methods' => WP_REST_Server::READABLE, 121 'callback' => [$this, 'getSubmissionAttachments'], 122 'permission_callback' => [$this, 'permissionViewSubmissionDetail'], 123 ]); 124 125 register_rest_route(self::NAMESPACE, '/submissions/(?P<submission_id>\d+)/attachments/(?P<attachment_id>\d+)/download', [ 126 'methods' => WP_REST_Server::READABLE, 127 'callback' => [$this, 'downloadSubmissionAttachment'], 128 'permission_callback' => [$this, 'permissionViewSubmissionDetail'], 129 ]); 130 131 register_rest_route(self::NAMESPACE, '/files/(?P<file_id>\d+)/binary', [ 132 'methods' => WP_REST_Server::READABLE, 133 'callback' => [$this, 'getFileBinary'], 134 'permission_callback' => [$this, 'permissionViewSubmissionDetail'], 135 ]); 136 137 register_rest_route(self::NAMESPACE, '/files/(?P<file_id>\d+)/thumbnail', [ 138 'methods' => WP_REST_Server::READABLE, 139 'callback' => [$this, 'getFileThumbnail'], 140 'permission_callback' => [$this, 'permissionViewSubmissionDetail'], 141 ]); 142 76 143 register_rest_route(self::NAMESPACE, '/submissions/(?P<submission_id>\d+)/share-link', [ 77 144 'methods' => WP_REST_Server::CREATABLE, … … 85 152 'permission_callback' => [$this, 'permissionBulkDelete'], 86 153 ]); 154 155 register_rest_route(self::NAMESPACE, '/submissions/read-state', [ 156 'methods' => WP_REST_Server::CREATABLE, 157 'callback' => [$this, 'setSubmissionReadState'], 158 'permission_callback' => [$this, 'permissionManageSubmissionReadState'], 159 ]); 87 160 } 88 161 … … 114 187 { 115 188 return $this->authorize(Capabilities::CAP_BULK_DELETE_SUBMISSIONS); 189 } 190 191 public function permissionMoveFormFolder(): bool|WP_Error 192 { 193 return $this->authorize(Capabilities::CAP_MOVE_FORM_FOLDER); 194 } 195 196 public function permissionRenameFolder(): bool|WP_Error 197 { 198 return $this->authorize(Capabilities::CAP_RENAME_FOLDER); 199 } 200 201 public function permissionCreateFolder(): bool|WP_Error 202 { 203 return $this->authorize(Capabilities::CAP_CREATE_FOLDER); 204 } 205 206 public function permissionRenameForm(): bool|WP_Error 207 { 208 return $this->authorize(Capabilities::CAP_RENAME_FORM); 209 } 210 211 public function permissionManageSubmissionReadState(): bool|WP_Error 212 { 213 return $this->authorize(Capabilities::CAP_MANAGE_SUBMISSION_READ_STATE); 116 214 } 117 215 … … 326 424 } 327 425 426 public function getFolderIconBinary(WP_REST_Request $request): WP_REST_Response|WP_Error 427 { 428 global $wpdb; 429 430 $folder_id = absint($request['folder_id']); 431 if ($folder_id <= 0) { 432 return new WP_Error('griffinforms_invalid_folder', __('Invalid folder.', 'griffinforms-form-builder'), ['status' => 400]); 433 } 434 435 $folder_table = $this->config->getTable('folder'); 436 $row = $wpdb->get_row( 437 $wpdb->prepare("SELECT id, icon FROM %i WHERE id = %d LIMIT 1", $folder_table, $folder_id), 438 ARRAY_A 439 ); 440 if (!is_array($row)) { 441 return new WP_Error('griffinforms_folder_not_found', __('Folder not found.', 'griffinforms-form-builder'), ['status' => 404]); 442 } 443 444 $icon_key = preg_replace('/[^a-zA-Z0-9_-]/', '', (string) ($row['icon'] ?? '')); 445 if ($icon_key === '') { 446 $icon_key = '1'; 447 } 448 449 $base_dir = trailingslashit($this->config->getRootPath() . 'admin/images/folder'); 450 $candidate = $base_dir . $icon_key . '.png'; 451 if (!file_exists($candidate)) { 452 $candidate = $base_dir . 'default.png'; 453 } 454 if (!file_exists($candidate)) { 455 return new WP_Error('griffinforms_folder_icon_not_found', __('Folder icon not found.', 'griffinforms-form-builder'), ['status' => 404]); 456 } 457 458 $binary = @file_get_contents($candidate); 459 if (!is_string($binary) || $binary === '') { 460 return new WP_Error('griffinforms_folder_icon_read_failed', __('Unable to read folder icon.', 'griffinforms-form-builder'), ['status' => 500]); 461 } 462 463 Capabilities::logAudit('folder_icon_viewed', 'success', $this->withEndpointMeta([ 464 'request_id' => $this->requestId(), 465 'target_type' => 'folder', 466 'target_id' => $folder_id, 467 'meta' => [ 468 'icon_key' => $icon_key, 469 'filename' => basename($candidate), 470 ], 471 ])); 472 473 return $this->success([ 474 'folder_id' => $folder_id, 475 'icon_key' => $icon_key, 476 'filename' => sanitize_file_name((string) basename($candidate)), 477 'mime' => 'image/png', 478 'size_bytes' => strlen($binary), 479 'content_base64' => base64_encode($binary), 480 'encoding' => 'base64', 481 ], [ 482 'request_id' => $this->requestId(), 483 ]); 484 } 485 328 486 public function getForms(WP_REST_Request $request): WP_REST_Response|WP_Error 329 487 { … … 449 607 } 450 608 609 public function moveFormFolder(WP_REST_Request $request): WP_REST_Response|WP_Error 610 { 611 global $wpdb; 612 613 $form_id = absint($request->get_param('form_id')); 614 $new_folder_id = $request->get_param('new_folder_id'); 615 if ($form_id <= 0 || $new_folder_id === null || $new_folder_id === '') { 616 return new WP_Error('griffinforms_invalid_payload', __('form_id and new_folder_id are required.', 'griffinforms-form-builder'), ['status' => 400]); 617 } 618 $new_folder_id = (int) $new_folder_id; 619 if ($new_folder_id < 0) { 620 return new WP_Error('griffinforms_invalid_folder', __('new_folder_id must be 0 or a valid folder ID.', 'griffinforms-form-builder'), ['status' => 400]); 621 } 622 623 $form_table = $this->config->getTable('form'); 624 $folder_table = $this->config->getTable('folder'); 625 626 $form_row = $wpdb->get_row( 627 $wpdb->prepare("SELECT id, folder FROM %i WHERE id = %d LIMIT 1", $form_table, $form_id), 628 ARRAY_A 629 ); 630 if (!is_array($form_row)) { 631 return new WP_Error('griffinforms_form_not_found', __('Form not found.', 'griffinforms-form-builder'), ['status' => 404]); 632 } 633 634 if ($new_folder_id > 0) { 635 $folder_exists = (int) $wpdb->get_var( 636 $wpdb->prepare("SELECT COUNT(*) FROM %i WHERE id = %d", $folder_table, $new_folder_id) 637 ); 638 if ($folder_exists <= 0) { 639 return new WP_Error('griffinforms_folder_not_found', __('Target folder not found.', 'griffinforms-form-builder'), ['status' => 404]); 640 } 641 } 642 643 $old_folder_id = absint($form_row['folder'] ?? 0); 644 if ($old_folder_id === $new_folder_id) { 645 return $this->success([ 646 'form_id' => $form_id, 647 'old_folder_id' => $old_folder_id, 648 'new_folder_id' => $new_folder_id, 649 'updated' => false, 650 ], [ 651 'request_id' => $this->requestId(), 652 ]); 653 } 654 655 $updated = $wpdb->update( 656 $form_table, 657 ['folder' => $new_folder_id], 658 ['id' => $form_id], 659 ['%d'], 660 ['%d'] 661 ); 662 663 if ($updated === false) { 664 return new WP_Error('griffinforms_folder_update_failed', __('Unable to update form folder.', 'griffinforms-form-builder'), ['status' => 500]); 665 } 666 667 Capabilities::logAudit('form_folder_changed', 'success', $this->withEndpointMeta([ 668 'request_id' => $this->requestId(), 669 'target_type' => 'form', 670 'target_id' => $form_id, 671 'meta' => [ 672 'old_folder_id' => $old_folder_id, 673 'new_folder_id' => $new_folder_id, 674 ], 675 ])); 676 677 return $this->success([ 678 'form_id' => $form_id, 679 'old_folder_id' => $old_folder_id, 680 'new_folder_id' => $new_folder_id, 681 'updated' => true, 682 ], [ 683 'request_id' => $this->requestId(), 684 ]); 685 } 686 687 public function renameFolder(WP_REST_Request $request): WP_REST_Response|WP_Error 688 { 689 global $wpdb; 690 691 $folder_id = absint($request->get_param('folder_id')); 692 $name = sanitize_text_field((string) $request->get_param('name')); 693 694 if ($folder_id <= 0 || $name === '') { 695 return new WP_Error('griffinforms_invalid_payload', __('folder_id and name are required.', 'griffinforms-form-builder'), ['status' => 400]); 696 } 697 698 if (mb_strlen($name) > 255) { 699 return new WP_Error('griffinforms_invalid_folder_name', __('Folder name is too long.', 'griffinforms-form-builder'), ['status' => 400]); 700 } 701 702 $folder_table = $this->config->getTable('folder'); 703 $folder_row = $wpdb->get_row( 704 $wpdb->prepare("SELECT id, name FROM %i WHERE id = %d LIMIT 1", $folder_table, $folder_id), 705 ARRAY_A 706 ); 707 if (!is_array($folder_row)) { 708 return new WP_Error('griffinforms_folder_not_found', __('Folder not found.', 'griffinforms-form-builder'), ['status' => 404]); 709 } 710 711 $old_name = sanitize_text_field((string) ($folder_row['name'] ?? '')); 712 if ($old_name === $name) { 713 return $this->success([ 714 'folder_id' => $folder_id, 715 'old_name' => $old_name, 716 'new_name' => $name, 717 'updated' => false, 718 ], [ 719 'request_id' => $this->requestId(), 720 ]); 721 } 722 723 $update_data = [ 724 'name' => $name, 725 'editor' => get_current_user_id(), 726 'edited_on' => current_time('mysql'), 727 ]; 728 $updated = $wpdb->update( 729 $folder_table, 730 $update_data, 731 ['id' => $folder_id], 732 ['%s', '%d', '%s'], 733 ['%d'] 734 ); 735 if ($updated === false) { 736 return new WP_Error('griffinforms_folder_rename_failed', __('Unable to rename folder.', 'griffinforms-form-builder'), ['status' => 500]); 737 } 738 739 Capabilities::logAudit('folder_renamed', 'success', $this->withEndpointMeta([ 740 'request_id' => $this->requestId(), 741 'target_type' => 'folder', 742 'target_id' => $folder_id, 743 'meta' => [ 744 'old_name' => $old_name, 745 'new_name' => $name, 746 ], 747 ])); 748 749 return $this->success([ 750 'folder_id' => $folder_id, 751 'old_name' => $old_name, 752 'new_name' => $name, 753 'updated' => true, 754 ], [ 755 'request_id' => $this->requestId(), 756 ]); 757 } 758 759 public function createFolder(WP_REST_Request $request): WP_REST_Response|WP_Error 760 { 761 global $wpdb; 762 763 $name = sanitize_text_field((string) $request->get_param('name')); 764 $heading = sanitize_text_field((string) $request->get_param('heading')); 765 $description = sanitize_textarea_field((string) $request->get_param('description')); 766 $icon_raw = sanitize_text_field((string) $request->get_param('icon')); 767 768 if ($name === '') { 769 return new WP_Error('griffinforms_invalid_payload', __('name is required.', 'griffinforms-form-builder'), ['status' => 400]); 770 } 771 if (mb_strlen($name) > 255) { 772 return new WP_Error('griffinforms_invalid_folder_name', __('Folder name is too long.', 'griffinforms-form-builder'), ['status' => 400]); 773 } 774 if ($heading !== '' && mb_strlen($heading) > 1000) { 775 return new WP_Error('griffinforms_invalid_folder_heading', __('Folder heading is too long.', 'griffinforms-form-builder'), ['status' => 400]); 776 } 777 if ($description !== '' && mb_strlen($description) > 2000) { 778 return new WP_Error('griffinforms_invalid_folder_description', __('Folder description is too long.', 'griffinforms-form-builder'), ['status' => 400]); 779 } 780 781 $icon_key = preg_replace('/[^a-zA-Z0-9_-]/', '', $icon_raw); 782 if ($icon_key === '') { 783 $icon_key = '1'; 784 } 785 if (!ctype_digit($icon_key)) { 786 $icon_key = '1'; 787 } 788 789 $icon_dir = trailingslashit($this->config->getRootPath() . 'admin/images/folder'); 790 $icon_path = $icon_dir . $icon_key . '.png'; 791 if (!file_exists($icon_path)) { 792 return new WP_Error('griffinforms_invalid_folder_icon', __('Invalid folder icon.', 'griffinforms-form-builder'), ['status' => 400]); 793 } 794 795 $folder_table = $this->config->getTable('folder'); 796 $author = (string) get_current_user_id(); 797 $created = current_time('mysql', true); 798 799 $inserted = $wpdb->insert( 800 $folder_table, 801 [ 802 'name' => $name, 803 'heading' => $heading, 804 'description' => $description, 805 'icon' => (int) $icon_key, 806 'author' => $author, 807 'created' => $created, 808 'last_editor' => $author, 809 'last_edited' => $created, 810 ], 811 ['%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s'] 812 ); 813 if ($inserted === false) { 814 return new WP_Error('griffinforms_folder_create_failed', __('Unable to create folder.', 'griffinforms-form-builder'), ['status' => 500]); 815 } 816 817 $folder_id = (int) $wpdb->insert_id; 818 819 Capabilities::logAudit('folder_created', 'success', $this->withEndpointMeta([ 820 'request_id' => $this->requestId(), 821 'target_type' => 'folder', 822 'target_id' => $folder_id, 823 'meta' => [ 824 'name' => $name, 825 'heading' => $heading, 826 'icon' => $icon_key, 827 ], 828 ])); 829 830 return $this->success([ 831 'folder_id' => $folder_id, 832 'name' => $name, 833 'heading' => $heading, 834 'description' => $description, 835 'icon' => (int) $icon_key, 836 'author' => $author, 837 'created' => $created, 838 'last_editor' => $author, 839 'last_edited' => $created, 840 ], [ 841 'request_id' => $this->requestId(), 842 ]); 843 } 844 845 public function renameForm(WP_REST_Request $request): WP_REST_Response|WP_Error 846 { 847 global $wpdb; 848 849 $form_id = absint($request->get_param('form_id')); 850 $name = sanitize_text_field((string) $request->get_param('name')); 851 852 if ($form_id <= 0 || $name === '') { 853 return new WP_Error('griffinforms_invalid_payload', __('form_id and name are required.', 'griffinforms-form-builder'), ['status' => 400]); 854 } 855 856 if (mb_strlen($name) > 255) { 857 return new WP_Error('griffinforms_invalid_form_name', __('Form name is too long.', 'griffinforms-form-builder'), ['status' => 400]); 858 } 859 860 $form_table = $this->config->getTable('form'); 861 $form_row = $wpdb->get_row( 862 $wpdb->prepare("SELECT id, name FROM %i WHERE id = %d LIMIT 1", $form_table, $form_id), 863 ARRAY_A 864 ); 865 if (!is_array($form_row)) { 866 return new WP_Error('griffinforms_form_not_found', __('Form not found.', 'griffinforms-form-builder'), ['status' => 404]); 867 } 868 869 $old_name = sanitize_text_field((string) ($form_row['name'] ?? '')); 870 if ($old_name === $name) { 871 return $this->success([ 872 'form_id' => $form_id, 873 'old_name' => $old_name, 874 'new_name' => $name, 875 'updated' => false, 876 ], [ 877 'request_id' => $this->requestId(), 878 ]); 879 } 880 881 $update_data = [ 882 'name' => $name, 883 'editor' => get_current_user_id(), 884 'edited_on' => current_time('mysql'), 885 ]; 886 $updated = $wpdb->update( 887 $form_table, 888 $update_data, 889 ['id' => $form_id], 890 ['%s', '%d', '%s'], 891 ['%d'] 892 ); 893 if ($updated === false) { 894 return new WP_Error('griffinforms_form_rename_failed', __('Unable to rename form.', 'griffinforms-form-builder'), ['status' => 500]); 895 } 896 897 Capabilities::logAudit('form_renamed', 'success', $this->withEndpointMeta([ 898 'request_id' => $this->requestId(), 899 'target_type' => 'form', 900 'target_id' => $form_id, 901 'meta' => [ 902 'old_name' => $old_name, 903 'new_name' => $name, 904 ], 905 ])); 906 907 return $this->success([ 908 'form_id' => $form_id, 909 'old_name' => $old_name, 910 'new_name' => $name, 911 'updated' => true, 912 ], [ 913 'request_id' => $this->requestId(), 914 ]); 915 } 916 917 public function getFormStructure(WP_REST_Request $request): WP_REST_Response|WP_Error 918 { 919 $form_id = absint($request['form_id']); 920 if ($form_id <= 0) { 921 return $this->apiError('form_structure_view_failed', 'griffinforms_invalid_form', __('Invalid form.', 'griffinforms-form-builder'), 400, [ 922 'target_type' => 'form', 923 'target_id' => $form_id, 924 ]); 925 } 926 927 $structure = AdminAppSql::getInstance()->getFormStructure($form_id); 928 if (!is_array($structure)) { 929 return $this->apiError('form_structure_view_failed', 'griffinforms_form_not_found', __('Form not found.', 'griffinforms-form-builder'), 404, [ 930 'target_type' => 'form', 931 'target_id' => $form_id, 932 ]); 933 } 934 935 $payload = [ 936 'id' => (int) ($structure['id'] ?? 0), 937 'name' => sanitize_text_field((string) ($structure['name'] ?? '')), 938 'heading' => sanitize_text_field((string) ($structure['heading'] ?? '')), 939 'description' => sanitize_textarea_field((string) ($structure['description'] ?? '')), 940 'pages' => [], 941 ]; 942 943 foreach ((array) ($structure['pages'] ?? []) as $page) { 944 $page_payload = [ 945 'id' => (int) ($page['id'] ?? 0), 946 'name' => sanitize_text_field((string) ($page['name'] ?? '')), 947 'heading' => sanitize_text_field((string) ($page['heading'] ?? '')), 948 'description' => sanitize_textarea_field((string) ($page['description'] ?? '')), 949 'rows' => [], 950 ]; 951 952 foreach ((array) ($page['rows'] ?? []) as $row) { 953 $row_payload = [ 954 'id' => (int) ($row['id'] ?? 0), 955 'name' => sanitize_text_field((string) ($row['name'] ?? '')), 956 'heading' => sanitize_text_field((string) ($row['heading'] ?? '')), 957 'description' => sanitize_textarea_field((string) ($row['description'] ?? '')), 958 'columns' => [], 959 ]; 960 961 foreach ((array) ($row['columns'] ?? []) as $column) { 962 $column_payload = [ 963 'id' => (int) ($column['id'] ?? 0), 964 'name' => sanitize_text_field((string) ($column['name'] ?? '')), 965 'width' => (int) ($column['width'] ?? 12), 966 'fields' => [], 967 ]; 968 969 foreach ((array) ($column['fields'] ?? []) as $field) { 970 $column_payload['fields'][] = [ 971 'id' => (int) ($field['id'] ?? 0), 972 'label' => sanitize_text_field((string) ($field['heading'] ?? '')), 973 'description' => sanitize_textarea_field((string) ($field['description'] ?? '')), 974 ]; 975 } 976 977 $row_payload['columns'][] = $column_payload; 978 } 979 980 $page_payload['rows'][] = $row_payload; 981 } 982 983 $payload['pages'][] = $page_payload; 984 } 985 986 Capabilities::logAudit('form_structure_viewed', 'success', $this->withEndpointMeta([ 987 'request_id' => $this->requestId(), 988 'target_type' => 'form', 989 'target_id' => $form_id, 990 ])); 991 992 return $this->success($payload, [ 993 'request_id' => $this->requestId(), 994 ]); 995 } 996 451 997 public function getSubmissions(WP_REST_Request $request): WP_REST_Response|WP_Error 452 998 { … … 476 1022 477 1023 $date_range = sanitize_key((string) $request->get_param('date_range')); 1024 $read_filter = sanitize_key((string) $request->get_param('read_filter')); 1025 $payment_filter = sanitize_key((string) $request->get_param('payment_filter')); 1026 $attachment_filter = sanitize_key((string) $request->get_param('attachment_filter')); 478 1027 $sort_by = sanitize_key((string) $request->get_param('sort_by')); 479 1028 $sort_dir = strtolower(sanitize_text_field((string) $request->get_param('sort_dir'))); … … 529 1078 } 530 1079 1080 $allowed_read_filters = ['', 'all', 'unread']; 1081 if (!in_array($read_filter, $allowed_read_filters, true)) { 1082 return new WP_Error('griffinforms_invalid_read_filter', __('Invalid read_filter parameter.', 'griffinforms-form-builder'), ['status' => 400]); 1083 } 1084 if ($read_filter === '') { 1085 $read_filter = 'all'; 1086 } 1087 1088 $allowed_payment_filters = ['', 'all', 'payment_incomplete']; 1089 if (!in_array($payment_filter, $allowed_payment_filters, true)) { 1090 return new WP_Error('griffinforms_invalid_payment_filter', __('Invalid payment_filter parameter.', 'griffinforms-form-builder'), ['status' => 400]); 1091 } 1092 if ($payment_filter === '') { 1093 $payment_filter = 'all'; 1094 } 1095 1096 $allowed_attachment_filters = ['', 'all', 'has_attachment']; 1097 if (!in_array($attachment_filter, $allowed_attachment_filters, true)) { 1098 return new WP_Error('griffinforms_invalid_attachment_filter', __('Invalid attachment_filter parameter.', 'griffinforms-form-builder'), ['status' => 400]); 1099 } 1100 if ($attachment_filter === '') { 1101 $attachment_filter = 'all'; 1102 } 1103 1104 if ($read_filter === 'unread') { 1105 $where[] = 's.is_read = %d'; 1106 $params[] = 0; 1107 } 1108 1109 if ($payment_filter === 'payment_incomplete') { 1110 $where[] = "(LOWER(TRIM(COALESCE(s.payment_status, 'pending'))) <> %s)"; 1111 $params[] = 'paid'; 1112 } 1113 1114 if ($attachment_filter === 'has_attachment') { 1115 $where[] = "EXISTS ( 1116 SELECT 1 FROM {$files_table} af 1117 WHERE af.submission_id = s.id AND af.status = %s 1118 )"; 1119 $params[] = 'attached'; 1120 } 1121 531 1122 [$date_from, $date_to] = $this->resolveDateRange($date_range); 532 1123 if ($date_from !== null) { … … 547 1138 $total = (int) $wpdb->get_var(empty($params) ? $count_sql : $wpdb->prepare($count_sql, ...$params)); 548 1139 549 $sql = "SELECT s.id, s.form_id, s.status, s. created, s.author, s.submission_meta,1140 $sql = "SELECT s.id, s.form_id, s.status, s.is_read, s.created, s.author, s.submission_meta, 550 1141 f.name AS form_name, 551 1142 COUNT(fl.id) AS attachment_count, 552 1143 COALESCE(SUM(fl.size_bytes), 0) AS attachment_size, 553 u.display_name AS submitter_name 1144 u.display_name AS submitter_name, 1145 u.user_email AS submitter_email 554 1146 FROM {$sub_table} s 555 1147 LEFT JOIN {$form_table} f ON f.id = s.form_id … … 574 1166 } 575 1167 1168 $author_id = absint($row['author'] ?? 0); 1169 $submitter_email = sanitize_email((string) ($row['submitter_email'] ?? '')); 1170 576 1171 $data[] = [ 577 1172 'id' => (int) $row['id'], … … 581 1176 'submission_compliance_profile' => $compliance_profile, 582 1177 'submission_status' => (int) ($row['status'] ?? 0) === 1 ? 'complete' : 'incomplete', 1178 'submission_read_state' => ((int) ($row['is_read'] ?? 0) === 1) ? 'read' : 'unread', 583 1179 'submission_date_time' => (string) ($row['created'] ?? ''), 584 1180 'submitter_name' => sanitize_text_field((string) ($row['submitter_name'] ?? '')), 1181 'submitter_avatar_url' => $this->buildSubmitterAvatarUrl($author_id, $submitter_email), 585 1182 'form_id' => (int) ($row['form_id'] ?? 0), 586 1183 'form_name' => sanitize_text_field((string) ($row['form_name'] ?? '')), … … 596 1193 'search' => $search, 597 1194 'date_range' => $date_range, 1195 'read_filter' => $read_filter, 1196 'payment_filter' => $payment_filter, 1197 'attachment_filter' => $attachment_filter, 598 1198 'form_id' => $form_id, 599 1199 'folder_id' => $folder_id, … … 613 1213 'search' => $search, 614 1214 'date_range' => $date_range, 1215 'read_filter' => $read_filter, 1216 'payment_filter' => $payment_filter, 1217 'attachment_filter' => $attachment_filter, 615 1218 'form_id' => $form_id, 616 1219 'folder_id' => $folder_id, … … 623 1226 $submission_id = absint($request['submission_id']); 624 1227 if ($submission_id <= 0) { 625 return new WP_Error('griffinforms_invalid_submission', __('Invalid submission.', 'griffinforms-form-builder'), ['status' => 400]); 1228 return $this->apiError('submission_summary_view_failed', 'griffinforms_invalid_submission', __('Invalid submission.', 'griffinforms-form-builder'), 400, [ 1229 'target_type' => 'submission', 1230 'target_id' => $submission_id, 1231 ]); 626 1232 } 627 1233 628 1234 $data = $this->fetchSubmissionSummaryById($submission_id); 629 1235 if ($data === null) { 630 return new WP_Error('griffinforms_submission_not_found', __('Submission not found.', 'griffinforms-form-builder'), ['status' => 404]); 631 } 1236 return $this->apiError('submission_summary_view_failed', 'griffinforms_submission_not_found', __('Submission not found.', 'griffinforms-form-builder'), 404, [ 1237 'target_type' => 'submission', 1238 'target_id' => $submission_id, 1239 ]); 1240 } 1241 1242 $this->markSubmissionAsRead($submission_id); 1243 $data['submission_read_state'] = 'read'; 632 1244 633 1245 Capabilities::logAudit('submission_summary_viewed', 'success', $this->withEndpointMeta([ … … 649 1261 $submission_id = absint($request['submission_id']); 650 1262 if ($submission_id <= 0) { 651 return new WP_Error('griffinforms_invalid_submission', __('Invalid submission.', 'griffinforms-form-builder'), ['status' => 400]); 1263 return $this->apiError('submission_detail_view_failed', 'griffinforms_invalid_submission', __('Invalid submission.', 'griffinforms-form-builder'), 400, [ 1264 'target_type' => 'submission', 1265 'target_id' => $submission_id, 1266 ]); 652 1267 } 653 1268 … … 655 1270 $form_table = $this->config->getTable('form'); 656 1271 $log_table = $this->config->getTable('log'); 1272 $users_table = $wpdb->users; 657 1273 658 1274 $row = $wpdb->get_row( … … 662 1278 663 1279 if (!is_array($row)) { 664 return new WP_Error('griffinforms_submission_not_found', __('Submission not found.', 'griffinforms-form-builder'), ['status' => 404]); 665 } 1280 return $this->apiError('submission_detail_view_failed', 'griffinforms_submission_not_found', __('Submission not found.', 'griffinforms-form-builder'), 404, [ 1281 'target_type' => 'submission', 1282 'target_id' => $submission_id, 1283 ]); 1284 } 1285 1286 $this->markSubmissionAsRead($submission_id); 666 1287 667 1288 $form_id = (int) ($row['form_id'] ?? 0); 1289 $author_id = absint($row['author'] ?? 0); 668 1290 $form_row = $wpdb->get_row( 669 1291 $wpdb->prepare("SELECT id, name, heading, description, compliance_profile, compliance_mode, last_edited FROM {$form_table} WHERE id = %d LIMIT 1", $form_id), 670 1292 ARRAY_A 671 1293 ); 1294 $author_row = []; 1295 if ($author_id > 0) { 1296 $author_row = $wpdb->get_row( 1297 $wpdb->prepare("SELECT display_name, user_email FROM {$users_table} WHERE ID = %d LIMIT 1", $author_id), 1298 ARRAY_A 1299 ); 1300 } 672 1301 673 1302 $submission_payload = $this->decodeArray($row['submission'] ?? ''); … … 695 1324 'id' => $submission_id, 696 1325 'submission_status' => (int) ($row['status'] ?? 0) === 1 ? 'complete' : 'incomplete', 1326 'submission_read_state' => 'read', 697 1327 'submission_date_time' => (string) ($row['created'] ?? ''), 1328 'submitter_name' => sanitize_text_field((string) ($author_row['display_name'] ?? '')), 1329 'submitter_email' => sanitize_email((string) ($author_row['user_email'] ?? '')), 1330 'submitter_avatar_url' => $this->buildSubmitterAvatarUrl( 1331 $author_id, 1332 sanitize_email((string) ($author_row['user_email'] ?? '')) 1333 ), 698 1334 'submission_payload' => $submission_payload, 699 1335 'submission_events_timeline' => $event_timeline, … … 723 1359 } 724 1360 1361 public function setSubmissionReadState(WP_REST_Request $request): WP_REST_Response|WP_Error 1362 { 1363 global $wpdb; 1364 1365 $submission_id = absint($request->get_param('submission_id')); 1366 $raw_state = $request->get_param('read_state'); 1367 if ($submission_id <= 0 || $raw_state === null) { 1368 return new WP_Error('griffinforms_invalid_payload', __('submission_id and read_state are required.', 'griffinforms-form-builder'), ['status' => 400]); 1369 } 1370 1371 $state = is_string($raw_state) ? strtolower(trim($raw_state)) : $raw_state; 1372 $is_read = null; 1373 if ($state === 'read' || $state === '1' || $state === 1 || $state === true) { 1374 $is_read = 1; 1375 } elseif ($state === 'unread' || $state === '0' || $state === 0 || $state === false) { 1376 $is_read = 0; 1377 } 1378 1379 if ($is_read === null) { 1380 return new WP_Error('griffinforms_invalid_read_state', __('read_state must be read/unread (or 1/0).', 'griffinforms-form-builder'), ['status' => 400]); 1381 } 1382 1383 $sub_table = $this->config->getTable('submission'); 1384 $existing = $wpdb->get_row( 1385 $wpdb->prepare("SELECT id, is_read FROM %i WHERE id = %d LIMIT 1", $sub_table, $submission_id), 1386 ARRAY_A 1387 ); 1388 if (!is_array($existing)) { 1389 return new WP_Error('griffinforms_submission_not_found', __('Submission not found.', 'griffinforms-form-builder'), ['status' => 404]); 1390 } 1391 1392 $old_state = (int) ($existing['is_read'] ?? 0); 1393 if ($old_state === $is_read) { 1394 return $this->success([ 1395 'submission_id' => $submission_id, 1396 'old_read_state' => $old_state === 1 ? 'read' : 'unread', 1397 'new_read_state' => $is_read === 1 ? 'read' : 'unread', 1398 'updated' => false, 1399 ], [ 1400 'request_id' => $this->requestId(), 1401 ]); 1402 } 1403 1404 $updated = $wpdb->update( 1405 $sub_table, 1406 ['is_read' => $is_read], 1407 ['id' => $submission_id], 1408 ['%d'], 1409 ['%d'] 1410 ); 1411 if ($updated === false) { 1412 return new WP_Error('griffinforms_read_state_update_failed', __('Unable to update submission read state.', 'griffinforms-form-builder'), ['status' => 500]); 1413 } 1414 1415 Capabilities::logAudit('submission_read_state_changed', 'success', $this->withEndpointMeta([ 1416 'request_id' => $this->requestId(), 1417 'target_type' => 'submission', 1418 'target_id' => $submission_id, 1419 'meta' => [ 1420 'old_read_state' => $old_state === 1 ? 'read' : 'unread', 1421 'new_read_state' => $is_read === 1 ? 'read' : 'unread', 1422 ], 1423 ])); 1424 1425 return $this->success([ 1426 'submission_id' => $submission_id, 1427 'old_read_state' => $old_state === 1 ? 'read' : 'unread', 1428 'new_read_state' => $is_read === 1 ? 'read' : 'unread', 1429 'updated' => true, 1430 ], [ 1431 'request_id' => $this->requestId(), 1432 ]); 1433 } 1434 1435 public function getRecentLogs(WP_REST_Request $request): WP_REST_Response|WP_Error 1436 { 1437 global $wpdb; 1438 1439 $count = absint($request->get_param('count')); 1440 if ($count <= 0) { 1441 $count = 20; 1442 } 1443 if ($count > 200) { 1444 return new WP_Error('griffinforms_count_limit', __('count cannot be greater than 200.', 'griffinforms-form-builder'), ['status' => 400]); 1445 } 1446 1447 $item_type = sanitize_key((string) $request->get_param('item_type')); 1448 $item_id = absint($request->get_param('item_id')); 1449 if ($item_type === '' || $item_id <= 0) { 1450 return new WP_Error('griffinforms_invalid_log_filter', __('item_type and item_id are required.', 'griffinforms-form-builder'), ['status' => 400]); 1451 } 1452 1453 $log_table = $this->config->getTable('log'); 1454 $rows = $wpdb->get_results( 1455 $wpdb->prepare( 1456 "SELECT id, log_type, category, item_type, item_id, user_id, message, date_time 1457 FROM %i 1458 WHERE item_type = %s AND item_id = %d 1459 ORDER BY id DESC 1460 LIMIT %d", 1461 $log_table, 1462 $item_type, 1463 $item_id, 1464 $count 1465 ), 1466 ARRAY_A 1467 ); 1468 1469 $data = array_values(array_map(static function ($row) { 1470 return [ 1471 'id' => absint($row['id'] ?? 0), 1472 'log_type' => sanitize_key((string) ($row['log_type'] ?? '')), 1473 'category' => sanitize_text_field((string) ($row['category'] ?? '')), 1474 'item_type' => sanitize_key((string) ($row['item_type'] ?? '')), 1475 'item_id' => absint($row['item_id'] ?? 0), 1476 'user_id' => absint($row['user_id'] ?? 0), 1477 'message' => sanitize_text_field((string) ($row['message'] ?? '')), 1478 'date_time' => (string) ($row['date_time'] ?? ''), 1479 ]; 1480 }, (array) $rows)); 1481 1482 Capabilities::logAudit('logs_recent_viewed', 'success', $this->withEndpointMeta([ 1483 'request_id' => $this->requestId(), 1484 'target_type' => $item_type . '_logs', 1485 'target_id' => $item_id, 1486 'meta' => [ 1487 'count' => $count, 1488 'result_count' => count($data), 1489 ], 1490 ])); 1491 1492 return $this->success($data, [ 1493 'item_type' => $item_type, 1494 'item_id' => $item_id, 1495 'count' => $count, 1496 'request_id' => $this->requestId(), 1497 ]); 1498 } 1499 725 1500 public function getSubmissionPdf(WP_REST_Request $request): WP_REST_Response|WP_Error 726 1501 { … … 766 1541 } 767 1542 1543 public function getSubmissionAttachments(WP_REST_Request $request): WP_REST_Response|WP_Error 1544 { 1545 global $wpdb; 1546 1547 $submission_id = absint($request['submission_id']); 1548 if ($submission_id <= 0) { 1549 return $this->apiError('submission_attachments_view_failed', 'griffinforms_invalid_submission', __('Invalid submission.', 'griffinforms-form-builder'), 400, [ 1550 'target_type' => 'submission', 1551 'target_id' => $submission_id, 1552 ]); 1553 } 1554 if (!$this->submissionExists($submission_id)) { 1555 return $this->apiError('submission_attachments_view_failed', 'griffinforms_submission_not_found', __('Submission not found.', 'griffinforms-form-builder'), 404, [ 1556 'target_type' => 'submission', 1557 'target_id' => $submission_id, 1558 ]); 1559 } 1560 1561 $files_table = $this->config->getTable('files'); 1562 $sub_table = $this->config->getTable('submission'); 1563 $uploaded_by_sql = $this->tableHasColumn($files_table, 'uploaded_by') 1564 ? ', uploaded_by' 1565 : ', 0 AS uploaded_by'; 1566 1567 $rows = $wpdb->get_results( 1568 $wpdb->prepare( 1569 "SELECT id, submission_id, status, path, url, mime, size_bytes, created_at{$uploaded_by_sql}, token 1570 FROM %i 1571 WHERE submission_id = %d AND status = %s 1572 ORDER BY id DESC", 1573 $files_table, 1574 $submission_id, 1575 'attached' 1576 ), 1577 ARRAY_A 1578 ); 1579 1580 if ($rows === null && !empty($wpdb->last_error)) { 1581 return $this->apiError('submission_attachments_view_failed', 'griffinforms_db_query_failed', __('Unable to load submission attachments.', 'griffinforms-form-builder'), 500, [ 1582 'target_type' => 'submission', 1583 'target_id' => $submission_id, 1584 'meta' => [ 1585 'db_error' => sanitize_text_field((string) $wpdb->last_error), 1586 ], 1587 ]); 1588 } 1589 1590 $attachment_rows_by_id = []; 1591 foreach ((array) $rows as $row) { 1592 $file_id = absint($row['id'] ?? 0); 1593 if ($file_id <= 0) { 1594 continue; 1595 } 1596 $attachment_rows_by_id[$file_id] = $row; 1597 } 1598 1599 $submission_payload_raw = $wpdb->get_var( 1600 $wpdb->prepare( 1601 "SELECT submission FROM {$sub_table} WHERE id = %d LIMIT 1", 1602 $submission_id 1603 ) 1604 ); 1605 1606 $payload_tokens = $this->extractFileTokensFromSubmissionPayload( 1607 $this->decodeArray($submission_payload_raw) 1608 ); 1609 1610 if (!empty($payload_tokens)) { 1611 if (count($payload_tokens) > 200) { 1612 return $this->apiError( 1613 'submission_attachments_view_failed', 1614 'griffinforms_attachment_token_limit', 1615 __('Too many attachment tokens in submission payload.', 'griffinforms-form-builder'), 1616 400, 1617 [ 1618 'target_type' => 'submission', 1619 'target_id' => $submission_id, 1620 'meta' => [ 1621 'token_count' => count($payload_tokens), 1622 ], 1623 ] 1624 ); 1625 } 1626 $token_placeholders = implode(', ', array_fill(0, count($payload_tokens), '%s')); 1627 $token_sql = "SELECT id, submission_id, status, path, url, mime, size_bytes, created_at{$uploaded_by_sql}, token 1628 FROM {$files_table} 1629 WHERE token IN ({$token_placeholders}) AND status = %s 1630 ORDER BY id DESC"; 1631 1632 $token_query_params = array_merge($payload_tokens, ['attached']); 1633 $token_rows = $wpdb->get_results( 1634 $wpdb->prepare($token_sql, ...$token_query_params), 1635 ARRAY_A 1636 ); 1637 1638 if ($token_rows === null && !empty($wpdb->last_error)) { 1639 return $this->apiError('submission_attachments_view_failed', 'griffinforms_db_query_failed', __('Unable to resolve token-linked attachments.', 'griffinforms-form-builder'), 500, [ 1640 'target_type' => 'submission', 1641 'target_id' => $submission_id, 1642 'meta' => [ 1643 'db_error' => sanitize_text_field((string) $wpdb->last_error), 1644 'token_count' => count($payload_tokens), 1645 ], 1646 ]); 1647 } 1648 1649 foreach ((array) $token_rows as $token_row) { 1650 $file_id = absint($token_row['id'] ?? 0); 1651 if ($file_id <= 0) { 1652 continue; 1653 } 1654 $attachment_rows_by_id[$file_id] = $token_row; 1655 } 1656 } 1657 1658 usort($attachment_rows_by_id, static function ($left, $right): int { 1659 return absint($right['id'] ?? 0) <=> absint($left['id'] ?? 0); 1660 }); 1661 1662 $data = []; 1663 foreach ($attachment_rows_by_id as $row) { 1664 $data[] = $this->mapAttachmentResponseRow($row, $submission_id); 1665 } 1666 1667 Capabilities::logAudit('submission_attachments_viewed', 'success', $this->withEndpointMeta([ 1668 'request_id' => $this->requestId(), 1669 'target_type' => 'submission', 1670 'target_id' => $submission_id, 1671 'meta' => [ 1672 'result_count' => count($data), 1673 'token_fallback_used' => !empty($payload_tokens), 1674 ], 1675 ])); 1676 1677 return $this->success($data, [ 1678 'submission_id' => $submission_id, 1679 'request_id' => $this->requestId(), 1680 ]); 1681 } 1682 1683 public function downloadSubmissionAttachment(WP_REST_Request $request): WP_REST_Response|WP_Error 1684 { 1685 global $wpdb; 1686 1687 $submission_id = absint($request['submission_id']); 1688 $attachment_id = absint($request['attachment_id']); 1689 if ($submission_id <= 0 || $attachment_id <= 0) { 1690 return $this->apiError('submission_attachment_download_failed', 'griffinforms_invalid_payload', __('submission_id and attachment_id are required.', 'griffinforms-form-builder'), 400, [ 1691 'target_type' => 'file', 1692 'target_id' => $attachment_id, 1693 'meta' => [ 1694 'submission_id' => $submission_id, 1695 ], 1696 ]); 1697 } 1698 if (!$this->submissionExists($submission_id)) { 1699 return $this->apiError('submission_attachment_download_failed', 'griffinforms_submission_not_found', __('Submission not found.', 'griffinforms-form-builder'), 404, [ 1700 'target_type' => 'submission', 1701 'target_id' => $submission_id, 1702 'meta' => [ 1703 'attachment_id' => $attachment_id, 1704 ], 1705 ]); 1706 } 1707 1708 $files_table = $this->config->getTable('files'); 1709 $row = $wpdb->get_row( 1710 $wpdb->prepare( 1711 "SELECT id, submission_id, status, path, url, mime, size_bytes 1712 FROM %i 1713 WHERE id = %d AND submission_id = %d 1714 LIMIT 1", 1715 $files_table, 1716 $attachment_id, 1717 $submission_id 1718 ), 1719 ARRAY_A 1720 ); 1721 1722 if (!is_array($row)) { 1723 return $this->apiError('submission_attachment_download_failed', 'griffinforms_file_not_found', __('Attachment not found.', 'griffinforms-form-builder'), 404, [ 1724 'target_type' => 'file', 1725 'target_id' => $attachment_id, 1726 'meta' => [ 1727 'submission_id' => $submission_id, 1728 ], 1729 ]); 1730 } 1731 1732 $status = sanitize_key((string) ($row['status'] ?? '')); 1733 if ($status !== 'attached') { 1734 return $this->apiError('submission_attachment_download_failed', 'griffinforms_file_not_available', __('Attachment is not available for download.', 'griffinforms-form-builder'), 404, [ 1735 'target_type' => 'file', 1736 'target_id' => $attachment_id, 1737 'meta' => [ 1738 'submission_id' => $submission_id, 1739 'file_status' => $status, 1740 ], 1741 ]); 1742 } 1743 1744 $path = (string) ($row['path'] ?? ''); 1745 $url = (string) ($row['url'] ?? ''); 1746 $binary = ''; 1747 1748 if ($path !== '' && file_exists($path)) { 1749 $contents = @file_get_contents($path); 1750 if (is_string($contents)) { 1751 $binary = $contents; 1752 } 1753 } elseif ($url !== '') { 1754 if (!$this->isTrustedAttachmentUrl($url)) { 1755 return $this->apiError('submission_attachment_download_failed', 'griffinforms_file_source_untrusted', __('Attachment URL is not trusted for download.', 'griffinforms-form-builder'), 422, [ 1756 'target_type' => 'file', 1757 'target_id' => $attachment_id, 1758 'meta' => [ 1759 'submission_id' => $submission_id, 1760 ], 1761 ]); 1762 } 1763 $response = wp_safe_remote_get($url, [ 1764 'timeout' => 20, 1765 'redirection' => 3, 1766 ]); 1767 if (!is_wp_error($response) && (int) wp_remote_retrieve_response_code($response) === 200) { 1768 $body = wp_remote_retrieve_body($response); 1769 if (is_string($body)) { 1770 $binary = $body; 1771 } 1772 } 1773 } 1774 1775 if ($binary === '') { 1776 return $this->apiError('submission_attachment_download_failed', 'griffinforms_file_read_failed', __('Unable to read file binary.', 'griffinforms-form-builder'), 500, [ 1777 'target_type' => 'file', 1778 'target_id' => $attachment_id, 1779 'meta' => [ 1780 'submission_id' => $submission_id, 1781 ], 1782 ]); 1783 } 1784 1785 $mime = sanitize_text_field((string) ($row['mime'] ?? '')); 1786 if ($mime === '') { 1787 $mime = 'application/octet-stream'; 1788 } 1789 1790 Capabilities::logAudit('submission_attachment_downloaded', 'success', $this->withEndpointMeta([ 1791 'request_id' => $this->requestId(), 1792 'target_type' => 'file', 1793 'target_id' => $attachment_id, 1794 'meta' => [ 1795 'submission_id' => $submission_id, 1796 'mime' => $mime, 1797 'size_bytes' => strlen($binary), 1798 ], 1799 ])); 1800 1801 return $this->success([ 1802 'id' => $attachment_id, 1803 'submission_id' => $submission_id, 1804 'filename' => $this->resolveAttachmentFilename($path, $url, $attachment_id), 1805 'mime' => $mime, 1806 'size_bytes' => absint($row['size_bytes'] ?? 0), 1807 'content_base64' => base64_encode($binary), 1808 'encoding' => 'base64', 1809 ], [ 1810 'request_id' => $this->requestId(), 1811 ]); 1812 } 1813 1814 public function getFileBinary(WP_REST_Request $request): WP_REST_Response|WP_Error 1815 { 1816 global $wpdb; 1817 1818 $file_id = absint($request['file_id']); 1819 if ($file_id <= 0) { 1820 return $this->apiError('file_binary_view_failed', 'griffinforms_invalid_file', __('Invalid file.', 'griffinforms-form-builder'), 400, [ 1821 'target_type' => 'file', 1822 'target_id' => $file_id, 1823 ]); 1824 } 1825 1826 $files_table = $this->config->getTable('files'); 1827 $row = $wpdb->get_row( 1828 $wpdb->prepare( 1829 "SELECT id, submission_id, status, path, url, mime, size_bytes 1830 FROM %i 1831 WHERE id = %d 1832 LIMIT 1", 1833 $files_table, 1834 $file_id 1835 ), 1836 ARRAY_A 1837 ); 1838 1839 if (!is_array($row)) { 1840 return $this->apiError('file_binary_view_failed', 'griffinforms_file_not_found', __('File not found.', 'griffinforms-form-builder'), 404, [ 1841 'target_type' => 'file', 1842 'target_id' => $file_id, 1843 ]); 1844 } 1845 1846 $status = sanitize_key((string) ($row['status'] ?? '')); 1847 if ($status !== 'attached') { 1848 return $this->apiError('file_binary_view_failed', 'griffinforms_file_not_available', __('File is not available for download.', 'griffinforms-form-builder'), 404, [ 1849 'target_type' => 'file', 1850 'target_id' => $file_id, 1851 'meta' => [ 1852 'file_status' => $status, 1853 ], 1854 ]); 1855 } 1856 1857 $path = (string) ($row['path'] ?? ''); 1858 $url = (string) ($row['url'] ?? ''); 1859 $binary = ''; 1860 1861 if ($path !== '' && file_exists($path)) { 1862 $contents = @file_get_contents($path); 1863 if (is_string($contents)) { 1864 $binary = $contents; 1865 } 1866 } elseif ($url !== '') { 1867 if (!$this->isTrustedAttachmentUrl($url)) { 1868 return $this->apiError('file_binary_view_failed', 'griffinforms_file_source_untrusted', __('Attachment URL is not trusted for download.', 'griffinforms-form-builder'), 422, [ 1869 'target_type' => 'file', 1870 'target_id' => $file_id, 1871 ]); 1872 } 1873 $response = wp_safe_remote_get($url, [ 1874 'timeout' => 20, 1875 'redirection' => 3, 1876 ]); 1877 if (!is_wp_error($response) && (int) wp_remote_retrieve_response_code($response) === 200) { 1878 $body = wp_remote_retrieve_body($response); 1879 if (is_string($body)) { 1880 $binary = $body; 1881 } 1882 } 1883 } 1884 1885 if ($binary === '') { 1886 return $this->apiError('file_binary_view_failed', 'griffinforms_file_read_failed', __('Unable to read file binary.', 'griffinforms-form-builder'), 500, [ 1887 'target_type' => 'file', 1888 'target_id' => $file_id, 1889 ]); 1890 } 1891 1892 $filename = $this->resolveAttachmentFilename($path, $url, $file_id); 1893 $mime = sanitize_text_field((string) ($row['mime'] ?? '')); 1894 if ($mime === '') { 1895 $mime = 'application/octet-stream'; 1896 } 1897 1898 Capabilities::logAudit('file_binary_viewed', 'success', $this->withEndpointMeta([ 1899 'request_id' => $this->requestId(), 1900 'target_type' => 'file', 1901 'target_id' => $file_id, 1902 'meta' => [ 1903 'submission_id' => absint($row['submission_id'] ?? 0), 1904 'mime' => $mime, 1905 'size_bytes' => strlen($binary), 1906 ], 1907 ])); 1908 1909 return $this->success([ 1910 'id' => $file_id, 1911 'submission_id' => absint($row['submission_id'] ?? 0), 1912 'filename' => $filename, 1913 'mime' => $mime, 1914 'size_bytes' => absint($row['size_bytes'] ?? 0), 1915 'content_base64' => base64_encode($binary), 1916 'encoding' => 'base64', 1917 ], [ 1918 'request_id' => $this->requestId(), 1919 ]); 1920 } 1921 1922 public function getFileThumbnail(WP_REST_Request $request): WP_REST_Response|WP_Error 1923 { 1924 global $wpdb; 1925 1926 $file_id = absint($request['file_id']); 1927 if ($file_id <= 0) { 1928 return $this->apiError('file_thumbnail_view_failed', 'griffinforms_invalid_file', __('Invalid file.', 'griffinforms-form-builder'), 400, [ 1929 'target_type' => 'file', 1930 'target_id' => $file_id, 1931 ]); 1932 } 1933 1934 $raw_max_dim = $request->get_param('max_dim'); 1935 $max_dim = absint($raw_max_dim); 1936 if ($raw_max_dim === null || $raw_max_dim === '') { 1937 $max_dim = 256; 1938 } 1939 if ($max_dim < 32 || $max_dim > 1024) { 1940 return $this->apiError('file_thumbnail_view_failed', 'griffinforms_invalid_max_dim', __('max_dim must be between 32 and 1024.', 'griffinforms-form-builder'), 400, [ 1941 'target_type' => 'file', 1942 'target_id' => $file_id, 1943 'meta' => [ 1944 'max_dim' => $max_dim, 1945 ], 1946 ]); 1947 } 1948 1949 $files_table = $this->config->getTable('files'); 1950 $row = $wpdb->get_row( 1951 $wpdb->prepare( 1952 "SELECT id, submission_id, status, path, mime 1953 FROM %i 1954 WHERE id = %d 1955 LIMIT 1", 1956 $files_table, 1957 $file_id 1958 ), 1959 ARRAY_A 1960 ); 1961 1962 if (!is_array($row)) { 1963 return $this->apiError('file_thumbnail_view_failed', 'griffinforms_file_not_found', __('File not found.', 'griffinforms-form-builder'), 404, [ 1964 'target_type' => 'file', 1965 'target_id' => $file_id, 1966 ]); 1967 } 1968 1969 $status = sanitize_key((string) ($row['status'] ?? '')); 1970 if ($status !== 'attached') { 1971 return $this->apiError('file_thumbnail_view_failed', 'griffinforms_file_not_available', __('File is not available for thumbnail.', 'griffinforms-form-builder'), 404, [ 1972 'target_type' => 'file', 1973 'target_id' => $file_id, 1974 'meta' => [ 1975 'file_status' => $status, 1976 ], 1977 ]); 1978 } 1979 1980 $mime = sanitize_text_field((string) ($row['mime'] ?? '')); 1981 if (strpos($mime, 'image/') !== 0) { 1982 return $this->apiError('file_thumbnail_view_failed', 'griffinforms_thumbnail_unavailable', __('Thumbnail is available only for image attachments.', 'griffinforms-form-builder'), 422, [ 1983 'target_type' => 'file', 1984 'target_id' => $file_id, 1985 'meta' => [ 1986 'mime' => $mime, 1987 ], 1988 ]); 1989 } 1990 1991 $path = (string) ($row['path'] ?? ''); 1992 if ($path === '' || !file_exists($path)) { 1993 return $this->apiError('file_thumbnail_view_failed', 'griffinforms_thumbnail_unavailable', __('Image file path is unavailable for thumbnail generation.', 'griffinforms-form-builder'), 422, [ 1994 'target_type' => 'file', 1995 'target_id' => $file_id, 1996 ]); 1997 } 1998 1999 if (!function_exists('wp_get_image_editor')) { 2000 require_once ABSPATH . 'wp-admin/includes/image.php'; 2001 } 2002 2003 $editor = wp_get_image_editor($path); 2004 if (is_wp_error($editor)) { 2005 return $this->apiError('file_thumbnail_view_failed', 'griffinforms_thumbnail_generation_failed', __('Unable to initialize image editor.', 'griffinforms-form-builder'), 500, [ 2006 'target_type' => 'file', 2007 'target_id' => $file_id, 2008 ]); 2009 } 2010 2011 $resize = $editor->resize($max_dim, $max_dim, false); 2012 if (is_wp_error($resize)) { 2013 return $this->apiError('file_thumbnail_view_failed', 'griffinforms_thumbnail_generation_failed', __('Unable to resize image.', 'griffinforms-form-builder'), 500, [ 2014 'target_type' => 'file', 2015 'target_id' => $file_id, 2016 'meta' => [ 2017 'max_dim' => $max_dim, 2018 ], 2019 ]); 2020 } 2021 2022 $saved = $editor->save(); 2023 if (!is_array($saved) || empty($saved['path'])) { 2024 return $this->apiError('file_thumbnail_view_failed', 'griffinforms_thumbnail_generation_failed', __('Unable to generate thumbnail.', 'griffinforms-form-builder'), 500, [ 2025 'target_type' => 'file', 2026 'target_id' => $file_id, 2027 ]); 2028 } 2029 2030 $thumb_path = (string) $saved['path']; 2031 $thumb_mime = sanitize_text_field((string) ($saved['mime-type'] ?? $mime)); 2032 $thumb_binary = @file_get_contents($thumb_path); 2033 if (!is_string($thumb_binary) || $thumb_binary === '') { 2034 return $this->apiError('file_thumbnail_view_failed', 'griffinforms_thumbnail_generation_failed', __('Unable to read thumbnail binary.', 'griffinforms-form-builder'), 500, [ 2035 'target_type' => 'file', 2036 'target_id' => $file_id, 2037 ]); 2038 } 2039 2040 // Cleanup generated derivative thumbnail if it is not the original file path. 2041 if ($thumb_path !== $path && file_exists($thumb_path)) { 2042 @unlink($thumb_path); 2043 } 2044 2045 Capabilities::logAudit('file_thumbnail_viewed', 'success', $this->withEndpointMeta([ 2046 'request_id' => $this->requestId(), 2047 'target_type' => 'file', 2048 'target_id' => $file_id, 2049 'meta' => [ 2050 'submission_id' => absint($row['submission_id'] ?? 0), 2051 'max_dim' => $max_dim, 2052 'mime' => $thumb_mime, 2053 'size_bytes' => strlen($thumb_binary), 2054 ], 2055 ])); 2056 2057 return $this->success([ 2058 'id' => $file_id, 2059 'submission_id' => absint($row['submission_id'] ?? 0), 2060 'mime' => $thumb_mime, 2061 'max_dim' => $max_dim, 2062 'size_bytes' => strlen($thumb_binary), 2063 'content_base64' => base64_encode($thumb_binary), 2064 'encoding' => 'base64', 2065 ], [ 2066 'request_id' => $this->requestId(), 2067 ]); 2068 } 2069 768 2070 public function createShareLink(WP_REST_Request $request): WP_REST_Response|WP_Error 769 2071 { … … 833 2135 $cached = get_transient($idem_cache_key); 834 2136 if (is_array($cached)) { 2137 Capabilities::logAudit('submissions_bulk_delete_idempotency_replayed', 'success', $this->withEndpointMeta([ 2138 'request_id' => $this->requestId(), 2139 'target_type' => 'submission_collection', 2140 'meta' => [ 2141 'idempotency_key' => $idempotency_key, 2142 ], 2143 ])); 835 2144 return $this->success($cached['data'] ?? [], $cached['meta'] ?? []); 836 2145 } … … 964 2273 } 965 2274 2275 private function extractFileTokensFromSubmissionPayload(array $payload): array 2276 { 2277 $tokens = []; 2278 $this->collectFileTokensFromValue($payload, $tokens); 2279 return array_values(array_unique($tokens)); 2280 } 2281 2282 private function collectFileTokensFromValue($value, array &$tokens): void 2283 { 2284 if (is_array($value)) { 2285 foreach ($value as $child) { 2286 $this->collectFileTokensFromValue($child, $tokens); 2287 } 2288 return; 2289 } 2290 2291 if (!is_string($value) || $value === '') { 2292 return; 2293 } 2294 2295 $parts = array_map('trim', explode(',', $value)); 2296 foreach ($parts as $part) { 2297 if ($part === '') { 2298 continue; 2299 } 2300 if (preg_match('/^[a-f0-9]{40}$/i', $part)) { 2301 $tokens[] = strtolower($part); 2302 } 2303 } 2304 } 2305 2306 private function mapAttachmentResponseRow(array $row, int $submission_id): array 2307 { 2308 $file_id = absint($row['id'] ?? 0); 2309 $path = (string) ($row['path'] ?? ''); 2310 $url = (string) ($row['url'] ?? ''); 2311 2312 return [ 2313 'id' => $file_id, 2314 'submission_id' => absint($row['submission_id'] ?? 0), 2315 'filename' => $this->resolveAttachmentFilename($path, $url, $file_id), 2316 'mime' => sanitize_text_field((string) ($row['mime'] ?? 'application/octet-stream')), 2317 'size_bytes' => absint($row['size_bytes'] ?? 0), 2318 'created_at' => (string) ($row['created_at'] ?? ''), 2319 'uploaded_by' => absint($row['uploaded_by'] ?? 0), 2320 'download_url' => esc_url_raw( 2321 rest_url(self::NAMESPACE . '/submissions/' . $submission_id . '/attachments/' . $file_id . '/download') 2322 ), 2323 ]; 2324 } 2325 2326 private function resolveAttachmentFilename(string $path, string $url, int $file_id): string 2327 { 2328 if ($path !== '') { 2329 $name = wp_basename($path); 2330 if ($name !== '') { 2331 return sanitize_file_name($name); 2332 } 2333 } 2334 2335 if ($url !== '') { 2336 $parsed = wp_parse_url($url); 2337 $candidate = isset($parsed['path']) ? wp_basename((string) $parsed['path']) : ''; 2338 if ($candidate !== '') { 2339 return sanitize_file_name($candidate); 2340 } 2341 } 2342 2343 return 'file-' . $file_id; 2344 } 2345 2346 private function buildSubmitterAvatarUrl(int $user_id, string $email = ''): string 2347 { 2348 $avatar_target = $user_id > 0 ? $user_id : $email; 2349 if ($avatar_target === '' || $avatar_target === 0) { 2350 return ''; 2351 } 2352 2353 $avatar_url = get_avatar_url($avatar_target, ['size' => 64]); 2354 return is_string($avatar_url) ? esc_url_raw($avatar_url) : ''; 2355 } 2356 966 2357 private function isValidShareToken(string $token, int $submission_id): bool 967 2358 { … … 1057 2448 $path = (string) parse_url($uri, PHP_URL_PATH); 1058 2449 return sanitize_text_field($path); 2450 } 2451 2452 private function isTrustedAttachmentUrl(string $url): bool 2453 { 2454 $url = esc_url_raw($url); 2455 if ($url === '' || !wp_http_validate_url($url)) { 2456 return false; 2457 } 2458 2459 $candidate_host = strtolower((string) wp_parse_url($url, PHP_URL_HOST)); 2460 if ($candidate_host === '') { 2461 return false; 2462 } 2463 2464 $trusted_hosts = []; 2465 $site_host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST)); 2466 if ($site_host !== '') { 2467 $trusted_hosts[$site_host] = true; 2468 } 2469 2470 $uploads = wp_get_upload_dir(); 2471 $uploads_baseurl = is_array($uploads) ? (string) ($uploads['baseurl'] ?? '') : ''; 2472 $uploads_host = strtolower((string) wp_parse_url($uploads_baseurl, PHP_URL_HOST)); 2473 if ($uploads_host !== '') { 2474 $trusted_hosts[$uploads_host] = true; 2475 } 2476 2477 return isset($trusted_hosts[$candidate_host]); 2478 } 2479 2480 private function apiError( 2481 string $action, 2482 string $code, 2483 string $message, 2484 int $status, 2485 array $context = [] 2486 ): WP_Error { 2487 $outcome = 'failed'; 2488 if ($status === 401 || $status === 403) { 2489 $outcome = 'denied'; 2490 } elseif ($status === 429) { 2491 $outcome = 'rate_limited'; 2492 } 2493 2494 $meta = isset($context['meta']) && is_array($context['meta']) ? $context['meta'] : []; 2495 $meta['status'] = $status; 2496 $meta['code'] = $code; 2497 $context['meta'] = $meta; 2498 $context['request_id'] = $this->requestId(); 2499 2500 Capabilities::logAudit($action, $outcome, $this->withEndpointMeta($context)); 2501 2502 return new WP_Error($code, $message, ['status' => $status]); 1059 2503 } 1060 2504 … … 1125 2569 } 1126 2570 2571 private function tableHasColumn(string $table, string $column): bool 2572 { 2573 global $wpdb; 2574 2575 if ($table === '' || $column === '') { 2576 return false; 2577 } 2578 2579 $result = $wpdb->get_var( 2580 $wpdb->prepare( 2581 "SHOW COLUMNS FROM {$table} LIKE %s", 2582 $column 2583 ) 2584 ); 2585 2586 return !empty($result); 2587 } 2588 2589 private function markSubmissionAsRead(int $submission_id): void 2590 { 2591 global $wpdb; 2592 2593 if ($submission_id <= 0) { 2594 return; 2595 } 2596 2597 $sub_table = $this->config->getTable('submission'); 2598 $wpdb->query( 2599 $wpdb->prepare( 2600 "UPDATE %i SET is_read = 1 WHERE id = %d AND is_read <> 1", 2601 $sub_table, 2602 $submission_id 2603 ) 2604 ); 2605 } 2606 1127 2607 private function fetchSubmissionSummaryById(int $submission_id): ?array 1128 2608 { … … 1140 2620 $row = $wpdb->get_row( 1141 2621 $wpdb->prepare( 1142 "SELECT s.id, s.form_id, s.status, s. created, s.submission_meta,2622 "SELECT s.id, s.form_id, s.status, s.is_read, s.created, s.author, s.submission_meta, 1143 2623 f.name AS form_name, 1144 2624 COUNT(fl.id) AS attachment_count, 1145 2625 COALESCE(SUM(fl.size_bytes), 0) AS attachment_size, 1146 u.display_name AS submitter_name 2626 u.display_name AS submitter_name, 2627 u.user_email AS submitter_email 1147 2628 FROM {$sub_table} s 1148 2629 LEFT JOIN {$form_table} f ON f.id = s.form_id … … 1175 2656 'submission_compliance_profile' => $compliance_profile, 1176 2657 'submission_status' => (int) ($row['status'] ?? 0) === 1 ? 'complete' : 'incomplete', 2658 'submission_read_state' => ((int) ($row['is_read'] ?? 0) === 1) ? 'read' : 'unread', 1177 2659 'submission_date_time' => (string) ($row['created'] ?? ''), 1178 2660 'submitter_name' => sanitize_text_field((string) ($row['submitter_name'] ?? '')), 2661 'submitter_avatar_url' => $this->buildSubmitterAvatarUrl( 2662 absint($row['author'] ?? 0), 2663 sanitize_email((string) ($row['submitter_email'] ?? '')) 2664 ), 1179 2665 'form_id' => (int) ($row['form_id'] ?? 0), 1180 2666 'form_name' => sanitize_text_field((string) ($row['form_name'] ?? '')), -
griffinforms-form-builder/trunk/includes/pipelines/jobworker.php
r3433300 r3475199 7 7 use GriffinForms\Log; 8 8 use GriffinForms\Includes\Pipelines\SubmissionContextBuilder; 9 use GriffinForms\Includes\Security\Capabilities; 9 10 10 11 class JobWorker … … 62 63 $this->handleAutoresponder($job, $payload); 63 64 break; 65 case 'webhook_submission_created': 66 $this->handleSubmissionCreatedWebhook($job, $payload); 67 break; 64 68 default: 65 69 throw new \RuntimeException(sprintf('Unknown job type %s', $job['job_type'] ?? 'unknown')); 66 70 } 71 } 72 73 /** 74 * Deliver signed outbound webhook for submission.created event. 75 */ 76 private function handleSubmissionCreatedWebhook(array $job, array $payload): void 77 { 78 $submission_id = absint($job['submission_id'] ?? 0); 79 $form_id = absint($job['form_id'] ?? 0); 80 $target_url = esc_url_raw((string) ($payload['target_url'] ?? '')); 81 $secret = (string) ($payload['secret'] ?? ''); 82 $timeout = (int) ($payload['timeout'] ?? 10); 83 $event = is_array($payload['event'] ?? null) ? $payload['event'] : []; 84 $event_id = sanitize_text_field((string) ($event['event_id'] ?? '')); 85 $event_type = sanitize_text_field((string) ($event['event_type'] ?? 'submission.created')); 86 87 if ($target_url === '' || !wp_http_validate_url($target_url) || $secret === '' || empty($event)) { 88 Capabilities::logAudit('webhook_submission_created_delivery_failed', 'failed', [ 89 'target_type' => 'submission', 90 'target_id' => $submission_id, 91 'meta' => [ 92 'channel' => 'api_webhook', 93 'request_method' => 'JOB', 94 'request_uri' => 'job://webhook_submission_created', 95 'job_type' => sanitize_text_field((string) ($job['job_type'] ?? '')), 96 'form_id' => $form_id, 97 'event_id' => $event_id, 98 'event_type' => $event_type, 99 'target_url' => $target_url, 100 'reason' => 'invalid_payload', 101 ], 102 ]); 103 throw new \RuntimeException('Webhook job payload invalid (url/secret/event missing).'); 104 } 105 106 $body = wp_json_encode($event); 107 if (!is_string($body) || $body === '') { 108 Capabilities::logAudit('webhook_submission_created_delivery_failed', 'failed', [ 109 'target_type' => 'submission', 110 'target_id' => $submission_id, 111 'meta' => [ 112 'channel' => 'api_webhook', 113 'request_method' => 'JOB', 114 'request_uri' => 'job://webhook_submission_created', 115 'job_type' => sanitize_text_field((string) ($job['job_type'] ?? '')), 116 'form_id' => $form_id, 117 'event_id' => $event_id, 118 'event_type' => $event_type, 119 'target_url' => $target_url, 120 'reason' => 'json_encode_failed', 121 ], 122 ]); 123 throw new \RuntimeException('Webhook event payload encoding failed.'); 124 } 125 126 $timestamp = isset($event['timestamp']) ? (int) $event['timestamp'] : time(); 127 $signed_payload = $timestamp . '.' . $body; 128 $signature = hash_hmac('sha256', $signed_payload, $secret); 129 130 $response = wp_safe_remote_post($target_url, [ 131 'timeout' => max(3, min(30, $timeout)), 132 'redirection' => 2, 133 'blocking' => true, 134 'headers' => [ 135 'Content-Type' => 'application/json', 136 'X-GriffinForms-Event-Id' => $event_id, 137 'X-GriffinForms-Event' => $event_type, 138 'X-GriffinForms-Timestamp' => (string) $timestamp, 139 'X-GriffinForms-Signature' => 't=' . $timestamp . ',v1=' . $signature, 140 ], 141 'body' => $body, 142 ]); 143 144 if (is_wp_error($response)) { 145 Capabilities::logAudit('webhook_submission_created_delivery_failed', 'failed', [ 146 'target_type' => 'submission', 147 'target_id' => $submission_id, 148 'meta' => [ 149 'channel' => 'api_webhook', 150 'request_method' => 'JOB', 151 'request_uri' => 'job://webhook_submission_created', 152 'job_type' => sanitize_text_field((string) ($job['job_type'] ?? '')), 153 'form_id' => $form_id, 154 'event_id' => $event_id, 155 'event_type' => $event_type, 156 'target_url' => $target_url, 157 'reason' => 'transport_error', 158 'error_message' => sanitize_text_field($response->get_error_message()), 159 ], 160 ]); 161 throw new \RuntimeException('Webhook delivery failed: ' . $response->get_error_message()); 162 } 163 164 $status_code = (int) wp_remote_retrieve_response_code($response); 165 if ($status_code < 200 || $status_code >= 300) { 166 $response_body = (string) wp_remote_retrieve_body($response); 167 $response_excerpt = mb_substr(sanitize_text_field($response_body), 0, 180); 168 Capabilities::logAudit('webhook_submission_created_delivery_failed', 'failed', [ 169 'target_type' => 'submission', 170 'target_id' => $submission_id, 171 'meta' => [ 172 'channel' => 'api_webhook', 173 'request_method' => 'JOB', 174 'request_uri' => 'job://webhook_submission_created', 175 'job_type' => sanitize_text_field((string) ($job['job_type'] ?? '')), 176 'form_id' => $form_id, 177 'event_id' => $event_id, 178 'event_type' => $event_type, 179 'target_url' => $target_url, 180 'reason' => 'non_2xx', 181 'status_code' => $status_code, 182 ], 183 ]); 184 throw new \RuntimeException(sprintf('Webhook returned HTTP %d: %s', $status_code, $response_excerpt)); 185 } 186 187 Capabilities::logAudit('webhook_submission_created_delivered', 'success', [ 188 'target_type' => 'submission', 189 'target_id' => $submission_id, 190 'meta' => [ 191 'channel' => 'api_webhook', 192 'request_method' => 'JOB', 193 'request_uri' => 'job://webhook_submission_created', 194 'job_type' => sanitize_text_field((string) ($job['job_type'] ?? '')), 195 'form_id' => $form_id, 196 'event_id' => $event_id, 197 'event_type' => $event_type, 198 'target_url' => $target_url, 199 'status_code' => $status_code, 200 ], 201 ]); 202 203 $this->log->add( 204 'info', 205 'submission', 206 $submission_id, 207 0, 208 sprintf('Webhook delivered (%s) to %s', $event_id, $target_url), 209 'API Webhook' 210 ); 67 211 } 68 212 -
griffinforms-form-builder/trunk/includes/security/capabilities.php
r3474296 r3475199 17 17 public const CAP_DELETE_SUBMISSION = 'griffinforms_delete_submission'; 18 18 public const CAP_BULK_DELETE_SUBMISSIONS = 'griffinforms_bulk_delete_submissions'; 19 public const CAP_MOVE_FORM_FOLDER = 'griffinforms_move_form_folder'; 20 public const CAP_CREATE_FOLDER = 'griffinforms_create_folder'; 21 public const CAP_RENAME_FOLDER = 'griffinforms_rename_folder'; 22 public const CAP_RENAME_FORM = 'griffinforms_rename_form'; 23 public const CAP_MANAGE_SUBMISSION_READ_STATE = 'griffinforms_manage_submission_read_state'; 19 24 20 25 public const OPTION_API_FEATURE_ENABLED = 'api_feature_enabled'; … … 43 48 static::CAP_DELETE_SUBMISSION, 44 49 static::CAP_BULK_DELETE_SUBMISSIONS, 50 static::CAP_MOVE_FORM_FOLDER, 51 static::CAP_CREATE_FOLDER, 52 static::CAP_RENAME_FOLDER, 53 static::CAP_RENAME_FORM, 54 static::CAP_MANAGE_SUBMISSION_READ_STATE, 45 55 ]; 46 56 } … … 56 66 static::CAP_DELETE_SUBMISSION => true, 57 67 static::CAP_BULK_DELETE_SUBMISSIONS => true, 68 static::CAP_MOVE_FORM_FOLDER => true, 69 static::CAP_CREATE_FOLDER => true, 70 static::CAP_RENAME_FOLDER => true, 71 static::CAP_RENAME_FORM => true, 72 static::CAP_MANAGE_SUBMISSION_READ_STATE => true, 58 73 ], 59 74 'editor' => [ … … 64 79 static::CAP_DELETE_SUBMISSION => false, 65 80 static::CAP_BULK_DELETE_SUBMISSIONS => false, 81 static::CAP_MOVE_FORM_FOLDER => true, 82 static::CAP_CREATE_FOLDER => true, 83 static::CAP_RENAME_FOLDER => true, 84 static::CAP_RENAME_FORM => true, 85 static::CAP_MANAGE_SUBMISSION_READ_STATE => true, 66 86 ], 67 87 'author' => [ … … 72 92 static::CAP_DELETE_SUBMISSION => false, 73 93 static::CAP_BULK_DELETE_SUBMISSIONS => false, 94 static::CAP_MOVE_FORM_FOLDER => false, 95 static::CAP_CREATE_FOLDER => false, 96 static::CAP_RENAME_FOLDER => false, 97 static::CAP_RENAME_FORM => false, 98 static::CAP_MANAGE_SUBMISSION_READ_STATE => false, 74 99 ], 75 100 'contributor' => [ … … 80 105 static::CAP_DELETE_SUBMISSION => false, 81 106 static::CAP_BULK_DELETE_SUBMISSIONS => false, 107 static::CAP_MOVE_FORM_FOLDER => false, 108 static::CAP_CREATE_FOLDER => false, 109 static::CAP_RENAME_FOLDER => false, 110 static::CAP_RENAME_FORM => false, 111 static::CAP_MANAGE_SUBMISSION_READ_STATE => false, 82 112 ], 83 113 'subscriber' => [ … … 88 118 static::CAP_DELETE_SUBMISSION => false, 89 119 static::CAP_BULK_DELETE_SUBMISSIONS => false, 120 static::CAP_MOVE_FORM_FOLDER => false, 121 static::CAP_CREATE_FOLDER => false, 122 static::CAP_RENAME_FOLDER => false, 123 static::CAP_RENAME_FORM => false, 124 static::CAP_MANAGE_SUBMISSION_READ_STATE => false, 90 125 ], 91 126 ]; … … 113 148 static::CAP_DELETE_SUBMISSION => true, 114 149 static::CAP_BULK_DELETE_SUBMISSIONS => true, 150 static::CAP_MOVE_FORM_FOLDER => true, 151 static::CAP_CREATE_FOLDER => true, 152 static::CAP_RENAME_FOLDER => true, 153 static::CAP_RENAME_FORM => true, 154 static::CAP_MANAGE_SUBMISSION_READ_STATE => true, 115 155 ] 116 156 : [ … … 121 161 static::CAP_DELETE_SUBMISSION => false, 122 162 static::CAP_BULK_DELETE_SUBMISSIONS => false, 163 static::CAP_MOVE_FORM_FOLDER => false, 164 static::CAP_CREATE_FOLDER => false, 165 static::CAP_RENAME_FOLDER => false, 166 static::CAP_RENAME_FORM => false, 167 static::CAP_MANAGE_SUBMISSION_READ_STATE => false, 123 168 ]; 124 169 } … … 201 246 $normalized[$role][$cap] = !empty($map[$cap]); 202 247 } 248 } 249 } 250 251 // Safety baseline: administrator should always retain full API capability set. 252 // This prevents accidental lockout and ensures new capabilities default to allowed. 253 if (isset($normalized['administrator']) && is_array($normalized['administrator'])) { 254 foreach ($caps as $cap) { 255 $normalized['administrator'][$cap] = true; 203 256 } 204 257 } … … 314 367 $category = 'audit'; 315 368 $request_uri = (string) ($_SERVER['REQUEST_URI'] ?? ''); 316 if ($request_uri !== '' && strpos($request_uri, '/wp-json/griffinforms/') !== false) { 369 $context_meta = isset($context['meta']) && is_array($context['meta']) ? $context['meta'] : []; 370 $context_channel = sanitize_key((string) ($context_meta['channel'] ?? '')); 371 if ( 372 ($request_uri !== '' && strpos($request_uri, '/wp-json/griffinforms/') !== false) || 373 in_array($context_channel, ['api', 'api_webhook'], true) 374 ) { 317 375 $category = 'api_audit'; 318 376 } -
griffinforms-form-builder/trunk/readme.txt
r3474296 r3475199 5 5 Tested up to: 6.9 6 6 Requires PHP: 8.2 7 Stable tag: 2.3. 5.07 Stable tag: 2.3.6.0 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 174 174 == Changelog == 175 175 176 = 2.3.6.0 – 2026-03-05 = 177 * Feature: Added companion-app API contracts for form structure, submission detail enrichment, attachment binaries, and image thumbnails. 178 * Feature: Added signed outbound `submission.created` webhook delivery contract for companion notification workflows. 179 * Hardening: Added strict API guardrails for attachment token limits, trusted file-source enforcement, and thumbnail dimension validation with typed errors. 180 * Hardening: Expanded API audit coverage for failure branches and idempotency replay visibility. 181 * Improvement: Refined capability matrix behavior and normalized API/webhook audit channel semantics for cleaner triage. 182 * Fix: Hardened Gutenberg block registration timing/path to avoid editor registration regressions. 183 176 184 = 2.3.5.0 – 2026-03-04 = 177 185 * Feature: Added companion-app-ready submissions API endpoints for folders, forms, submissions, detail, PDF export, share links, and bulk delete. … … 233 241 234 242 == Upgrade Notice == 243 244 = 2.3.6.0 = 245 Companion-app API completion release: form structure/detail/attachment contracts, signed submission webhook delivery, and stronger API guardrails and audit diagnostics. Recommended update. 235 246 236 247 = 2.3.5.0 = -
griffinforms-form-builder/trunk/settings.php
r3446504 r3475199 64 64 'turnstile_secret_key', 65 65 'sendgrid_api_key', 66 'smtp_password' 66 'smtp_password', 67 'api_webhook_submission_created_secret' 67 68 ]; 68 69 }
Note: See TracChangeset
for help on using the changeset viewer.