Plugin Directory

Changeset 3475199


Ignore:
Timestamp:
03/05/2026 07:12:52 AM (6 days ago)
Author:
griffinforms
Message:

Release 2.3.6.0

Location:
griffinforms-form-builder/trunk
Files:
1 added
16 edited

Legend:

Unmodified
Added
Removed
  • griffinforms-form-builder/trunk/admin/html/pages/settings/capabilitymatrix.php

    r3474375 r3475199  
    44
    55use GriffinForms\Includes\Security\Capabilities;
     6use GriffinForms\Includes\Api\WebhookDelivery;
    67
    78class CapabilityMatrix extends \GriffinForms\Admin\Html\Pages\Settings\Format
     
    3031        $this->getOptionHtml('api_feature_enabled');
    3132        $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');
    3237        $this->getOptionHtml('api_role_capability_map');
    3338        $this->getOptionHtml('api_user_capability_overrides');
     
    5459    }
    5560
     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
    5695    protected function getApiRoleCapabilityMapHtml($input_id)
    5796    {
     
    5998        $role_names = is_object($roles) ? $roles->get_names() : [];
    6099
    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'),
    68118        ];
    69119
    70120        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;">';
    72149        echo '<thead><tr><th style="padding-left: 12px;">' . esc_html__('Role', 'griffinforms-form-builder') . '</th>';
    73150        foreach ($cap_labels as $label) {
     
    91168
    92169        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>';
    96170    }
    97171
  • griffinforms-form-builder/trunk/admin/html/pages/single/submission.php

    r3455761 r3475199  
    252252        }
    253253
     254        $this->markSubmissionAsRead();
     255
    254256        $this->setCart();
    255257        $this->setPaymentMeta();
     
    327329        echo '</div>';
    328330        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        );
    329353    }
    330354
  • griffinforms-form-builder/trunk/admin/js/local/capabilitymatrix.php

    r3474296 r3475199  
    1010              '    getApiFeatureEnabledOption();' . PHP_EOL .
    1111              '    getApiKillSwitchOption();' . PHP_EOL .
     12              '    getApiWebhookSubmissionCreatedEnabledOption();' . PHP_EOL .
     13              '    getApiWebhookSubmissionCreatedUrlOption();' . PHP_EOL .
     14              '    getApiWebhookSubmissionCreatedSecretOption();' . PHP_EOL .
     15              '    getApiWebhookSubmissionCreatedTimeoutOption();' . PHP_EOL .
    1216              '    getApiRoleCapabilityMapOption();' . PHP_EOL .
    1317              '    getApiUserCapabilityOverridesOption();' . PHP_EOL .
     
    1620        $js .= $this->getApiFeatureEnabledOptionJs();
    1721        $js .= $this->getApiKillSwitchOptionJs();
     22        $js .= $this->getApiWebhookSubmissionCreatedEnabledOptionJs();
     23        $js .= $this->getApiWebhookSubmissionCreatedUrlOptionJs();
     24        $js .= $this->getApiWebhookSubmissionCreatedSecretOptionJs();
     25        $js .= $this->getApiWebhookSubmissionCreatedTimeoutOptionJs();
    1826        $js .= $this->getApiRoleCapabilityMapOptionJs();
    1927        $js .= $this->getApiUserCapabilityOverridesOptionJs();
     
    3341        return 'function getApiKillSwitchOption() {' . PHP_EOL .
    3442               '    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 .
    3571               '}' . PHP_EOL;
    3672    }
  • griffinforms-form-builder/trunk/admin/language/capabilitymatrix.php

    r3474296 r3475199  
    99    protected function capabilitymatrixTitle()
    1010    {
    11         return __('Capability Matrix', 'griffinforms-form-builder');
     11        return __('API Access & Webhooks', 'griffinforms-form-builder');
    1212    }
    1313
     
    3030    {
    3131        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');
    3272    }
    3373
  • griffinforms-form-builder/trunk/admin/language/pagetitles.php

    r3474296 r3475199  
    6767    protected function capabilityMatrixTitle()
    6868    {
    69         return __('Capability Matrix', 'griffinforms-form-builder');
     69        return __('API Access & Webhooks', 'griffinforms-form-builder');
    7070    }
    7171}
  • griffinforms-form-builder/trunk/admin/language/settings.php

    r3474296 r3475199  
    4444    protected function capabilitymatrixTitle()
    4545    {
    46         return __('Capability Matrix', 'griffinforms-form-builder');
     46        return __('API Access & Webhooks', 'griffinforms-form-builder');
    4747    }
    4848   
  • griffinforms-form-builder/trunk/admin/secure/capabilitymatrix.php

    r3474296 r3475199  
    3636        return Capabilities::normalizeUserOverrides($value);
    3737    }
     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    }
    3876}
  • griffinforms-form-builder/trunk/config.php

    r3474296 r3475199  
    55class Config
    66{
    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';
    99    public const PHP_REQUIRED = '8.2';
    1010    public const WP_REQUIRED = '6.2';
  • griffinforms-form-builder/trunk/db.php

    r3455761 r3475199  
    202202        form_id mediumint(9),
    203203        status tinyint(1) NOT NULL DEFAULT 0,
     204        is_read tinyint(1) NOT NULL DEFAULT 0,
    204205        is_payment_form tinyint(1) NOT NULL DEFAULT 0,
    205206        payment_status varchar(20) NOT NULL DEFAULT 'pending',
     
    715716        }
    716717
     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
    717730        $payment_meta_exists = $wpdb->get_results($wpdb->prepare(
    718731            "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  
    1414    {
    1515        Rest::registerRoutes();
     16        static::registerBlockType();
     17    }
    1618
    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        }
    2027
    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';
    2431
    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        );
    3242    }
    3343
  • griffinforms-form-builder/trunk/griffinforms.php

    r3474296 r3475199  
    44 * Plugin URI:        https://griffinforms.com/
    55 * 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.0
     6 * Version:           2.3.6.0
    77 * Requires at least: 6.6
    88 * Requires PHP:      8.2
     
    116116Includes\Security\Capabilities::registerHooks();
    117117new Includes\Api\SubmissionsRest();
     118Includes\Api\WebhookDelivery::registerHooks();
    118119
    119120add_action('plugins_loaded', function () {
  • griffinforms-form-builder/trunk/includes/api/submissionsrest.php

    r3474296 r3475199  
    77use GriffinForms\Settings;
    88use GriffinForms\Includes\Security\Capabilities;
     9use GriffinForms\Admin\Sql\App as AdminAppSql;
    910use GriffinForms\Admin\Sql\Format as AdminSqlFormat;
    1011use GriffinForms\Admin\Sql\Submissions as AdminSubmissionsSql;
     
    4445        ]);
    4546
     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
    4665        register_rest_route(self::NAMESPACE, '/forms', [
    4766            'methods' => WP_REST_Server::READABLE,
     
    5069        ]);
    5170
     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
    5289        register_rest_route(self::NAMESPACE, '/submissions', [
    5390            'methods' => WP_REST_Server::READABLE,
     
    68105        ]);
    69106
     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
    70113        register_rest_route(self::NAMESPACE, '/submissions/(?P<submission_id>\d+)/pdf', [
    71114            'methods' => WP_REST_Server::READABLE,
     
    74117        ]);
    75118
     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
    76143        register_rest_route(self::NAMESPACE, '/submissions/(?P<submission_id>\d+)/share-link', [
    77144            'methods' => WP_REST_Server::CREATABLE,
     
    85152            'permission_callback' => [$this, 'permissionBulkDelete'],
    86153        ]);
     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        ]);
    87160    }
    88161
     
    114187    {
    115188        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);
    116214    }
    117215
     
    326424    }
    327425
     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
    328486    public function getForms(WP_REST_Request $request): WP_REST_Response|WP_Error
    329487    {
     
    449607    }
    450608
     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
    451997    public function getSubmissions(WP_REST_Request $request): WP_REST_Response|WP_Error
    452998    {
     
    4761022
    4771023        $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'));
    4781027        $sort_by = sanitize_key((string) $request->get_param('sort_by'));
    4791028        $sort_dir = strtolower(sanitize_text_field((string) $request->get_param('sort_dir')));
     
    5291078        }
    5301079
     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
    5311122        [$date_from, $date_to] = $this->resolveDateRange($date_range);
    5321123        if ($date_from !== null) {
     
    5471138        $total = (int) $wpdb->get_var(empty($params) ? $count_sql : $wpdb->prepare($count_sql, ...$params));
    5481139
    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,
    5501141                       f.name AS form_name,
    5511142                       COUNT(fl.id) AS attachment_count,
    5521143                       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
    5541146                FROM {$sub_table} s
    5551147                LEFT JOIN {$form_table} f ON f.id = s.form_id
     
    5741166            }
    5751167
     1168            $author_id = absint($row['author'] ?? 0);
     1169            $submitter_email = sanitize_email((string) ($row['submitter_email'] ?? ''));
     1170
    5761171            $data[] = [
    5771172                'id' => (int) $row['id'],
     
    5811176                'submission_compliance_profile' => $compliance_profile,
    5821177                'submission_status' => (int) ($row['status'] ?? 0) === 1 ? 'complete' : 'incomplete',
     1178                'submission_read_state' => ((int) ($row['is_read'] ?? 0) === 1) ? 'read' : 'unread',
    5831179                'submission_date_time' => (string) ($row['created'] ?? ''),
    5841180                'submitter_name' => sanitize_text_field((string) ($row['submitter_name'] ?? '')),
     1181                'submitter_avatar_url' => $this->buildSubmitterAvatarUrl($author_id, $submitter_email),
    5851182                'form_id' => (int) ($row['form_id'] ?? 0),
    5861183                'form_name' => sanitize_text_field((string) ($row['form_name'] ?? '')),
     
    5961193                'search' => $search,
    5971194                'date_range' => $date_range,
     1195                'read_filter' => $read_filter,
     1196                'payment_filter' => $payment_filter,
     1197                'attachment_filter' => $attachment_filter,
    5981198                'form_id' => $form_id,
    5991199                'folder_id' => $folder_id,
     
    6131213            'search' => $search,
    6141214            'date_range' => $date_range,
     1215            'read_filter' => $read_filter,
     1216            'payment_filter' => $payment_filter,
     1217            'attachment_filter' => $attachment_filter,
    6151218            'form_id' => $form_id,
    6161219            'folder_id' => $folder_id,
     
    6231226        $submission_id = absint($request['submission_id']);
    6241227        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            ]);
    6261232        }
    6271233
    6281234        $data = $this->fetchSubmissionSummaryById($submission_id);
    6291235        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';
    6321244
    6331245        Capabilities::logAudit('submission_summary_viewed', 'success', $this->withEndpointMeta([
     
    6491261        $submission_id = absint($request['submission_id']);
    6501262        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            ]);
    6521267        }
    6531268
     
    6551270        $form_table = $this->config->getTable('form');
    6561271        $log_table = $this->config->getTable('log');
     1272        $users_table = $wpdb->users;
    6571273
    6581274        $row = $wpdb->get_row(
     
    6621278
    6631279        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);
    6661287
    6671288        $form_id = (int) ($row['form_id'] ?? 0);
     1289        $author_id = absint($row['author'] ?? 0);
    6681290        $form_row = $wpdb->get_row(
    6691291            $wpdb->prepare("SELECT id, name, heading, description, compliance_profile, compliance_mode, last_edited FROM {$form_table} WHERE id = %d LIMIT 1", $form_id),
    6701292            ARRAY_A
    6711293        );
     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        }
    6721301
    6731302        $submission_payload = $this->decodeArray($row['submission'] ?? '');
     
    6951324            'id' => $submission_id,
    6961325            'submission_status' => (int) ($row['status'] ?? 0) === 1 ? 'complete' : 'incomplete',
     1326            'submission_read_state' => 'read',
    6971327            '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            ),
    6981334            'submission_payload' => $submission_payload,
    6991335            'submission_events_timeline' => $event_timeline,
     
    7231359    }
    7241360
     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
    7251500    public function getSubmissionPdf(WP_REST_Request $request): WP_REST_Response|WP_Error
    7261501    {
     
    7661541    }
    7671542
     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
    7682070    public function createShareLink(WP_REST_Request $request): WP_REST_Response|WP_Error
    7692071    {
     
    8332135        $cached = get_transient($idem_cache_key);
    8342136        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            ]));
    8352144            return $this->success($cached['data'] ?? [], $cached['meta'] ?? []);
    8362145        }
     
    9642273    }
    9652274
     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
    9662357    private function isValidShareToken(string $token, int $submission_id): bool
    9672358    {
     
    10572448        $path = (string) parse_url($uri, PHP_URL_PATH);
    10582449        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]);
    10592503    }
    10602504
     
    11252569    }
    11262570
     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
    11272607    private function fetchSubmissionSummaryById(int $submission_id): ?array
    11282608    {
     
    11402620        $row = $wpdb->get_row(
    11412621            $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,
    11432623                        f.name AS form_name,
    11442624                        COUNT(fl.id) AS attachment_count,
    11452625                        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
    11472628                 FROM {$sub_table} s
    11482629                 LEFT JOIN {$form_table} f ON f.id = s.form_id
     
    11752656            'submission_compliance_profile' => $compliance_profile,
    11762657            'submission_status' => (int) ($row['status'] ?? 0) === 1 ? 'complete' : 'incomplete',
     2658            'submission_read_state' => ((int) ($row['is_read'] ?? 0) === 1) ? 'read' : 'unread',
    11772659            'submission_date_time' => (string) ($row['created'] ?? ''),
    11782660            '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            ),
    11792665            'form_id' => (int) ($row['form_id'] ?? 0),
    11802666            'form_name' => sanitize_text_field((string) ($row['form_name'] ?? '')),
  • griffinforms-form-builder/trunk/includes/pipelines/jobworker.php

    r3433300 r3475199  
    77use GriffinForms\Log;
    88use GriffinForms\Includes\Pipelines\SubmissionContextBuilder;
     9use GriffinForms\Includes\Security\Capabilities;
    910
    1011class JobWorker
     
    6263                $this->handleAutoresponder($job, $payload);
    6364                break;
     65            case 'webhook_submission_created':
     66                $this->handleSubmissionCreatedWebhook($job, $payload);
     67                break;
    6468            default:
    6569                throw new \RuntimeException(sprintf('Unknown job type %s', $job['job_type'] ?? 'unknown'));
    6670        }
     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        );
    67211    }
    68212
  • griffinforms-form-builder/trunk/includes/security/capabilities.php

    r3474296 r3475199  
    1717    public const CAP_DELETE_SUBMISSION = 'griffinforms_delete_submission';
    1818    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';
    1924
    2025    public const OPTION_API_FEATURE_ENABLED = 'api_feature_enabled';
     
    4348            static::CAP_DELETE_SUBMISSION,
    4449            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,
    4555        ];
    4656    }
     
    5666                static::CAP_DELETE_SUBMISSION => true,
    5767                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,
    5873            ],
    5974            'editor' => [
     
    6479                static::CAP_DELETE_SUBMISSION => false,
    6580                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,
    6686            ],
    6787            'author' => [
     
    7292                static::CAP_DELETE_SUBMISSION => false,
    7393                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,
    7499            ],
    75100            'contributor' => [
     
    80105                static::CAP_DELETE_SUBMISSION => false,
    81106                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,
    82112            ],
    83113            'subscriber' => [
     
    88118                static::CAP_DELETE_SUBMISSION => false,
    89119                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,
    90125            ],
    91126        ];
     
    113148                            static::CAP_DELETE_SUBMISSION => true,
    114149                            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,
    115155                        ]
    116156                        : [
     
    121161                            static::CAP_DELETE_SUBMISSION => false,
    122162                            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,
    123168                        ];
    124169                }
     
    201246                    $normalized[$role][$cap] = !empty($map[$cap]);
    202247                }
     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;
    203256            }
    204257        }
     
    314367            $category = 'audit';
    315368            $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            ) {
    317375                $category = 'api_audit';
    318376            }
  • griffinforms-form-builder/trunk/readme.txt

    r3474296 r3475199  
    55Tested up to: 6.9
    66Requires PHP: 8.2
    7 Stable tag: 2.3.5.0
     7Stable tag: 2.3.6.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    174174== Changelog ==
    175175
     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
    176184= 2.3.5.0 – 2026-03-04 =
    177185* Feature: Added companion-app-ready submissions API endpoints for folders, forms, submissions, detail, PDF export, share links, and bulk delete.
     
    233241
    234242== Upgrade Notice ==
     243
     244= 2.3.6.0 =
     245Companion-app API completion release: form structure/detail/attachment contracts, signed submission webhook delivery, and stronger API guardrails and audit diagnostics. Recommended update.
    235246
    236247= 2.3.5.0 =
  • griffinforms-form-builder/trunk/settings.php

    r3446504 r3475199  
    6464            'turnstile_secret_key',
    6565            'sendgrid_api_key',
    66             'smtp_password'
     66            'smtp_password',
     67            'api_webhook_submission_created_secret'
    6768        ];
    6869    }
Note: See TracChangeset for help on using the changeset viewer.