Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion assets/js/frontend.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,21 @@
const self = $( this ),
submit = self.find( 'input[type=submit]' ),
body = self.closest( '.wedocs-modal-body' ),
data = self.serialize() + '&_wpnonce=' + weDocs_Vars.nonce;
consentCheckbox = self.find( '#wedocs-gdpr-consent' );

// Validate GDPR consent if checkbox exists.
if ( consentCheckbox.length && ! consentCheckbox.is( ':checked' ) ) {
$( '#wedocs-modal-errors', body )
.empty()
.append(
'<div class="wedocs-alert wedocs-alert-danger">' +
weDocs_Vars.gdprConsentError +
'</div>'
);
return;
Comment on lines +197 to +206

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Enforce GDPR consent on the server too.

This check is bypassable with a direct POST to admin-ajax.php. includes/Ajax.php:205-246 still processes the message without reading gdpr_consent, so the modal endpoint does not actually require consent. Reject missing consent in the AJAX/API handlers before wedocs_before_send_contact_email runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@assets/js/frontend.js` around lines 197 - 206, The GDPR consent check in the
frontend modal is only client-side and can be bypassed with a direct request, so
enforce it in the server-side AJAX flow as well. Update the contact submission
handling in Ajax::send_contact_email (and any related API handler that reaches
wedocs_before_send_contact_email) to read gdpr_consent from the request and
reject the request early when consent is missing before any email-sending or
hook execution occurs. Keep the existing frontend validation, but make the
server the source of truth for consent.

}

const data = self.serialize() + '&_wpnonce=' + weDocs_Vars.nonce;

submit.prop( 'disabled', true );

Expand Down
16 changes: 16 additions & 0 deletions includes/API/API.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ public function __construct( $api ) {
// Register upgrader api.
$upgrader_api = new UpgraderApi( $api );
$upgrader_api->register_api();

// Register messages api.
$messages_api = new MessagesApi( $api );
$messages_api->register_api();
}

/**
Expand Down Expand Up @@ -642,6 +646,18 @@ public function handle_feedback( $request ) {
$email = $user->user_email;
}

$email_to = wedocs_get_general_settings( 'email_to', get_option( 'admin_email' ) );

do_action( 'wedocs_before_send_contact_email', [
'name' => $name,
'email' => $email,
'subject' => $request['subject'],
'message' => $request['message'],
'doc_id' => $id,
'recipients' => $email_to,
'source' => 'modal',
] );

wedocs_doc_feedback_email( $id, $name, $email, $request['subject'], $request['message'] );

return rest_ensure_response( [
Expand Down
301 changes: 301 additions & 0 deletions includes/API/MessagesApi.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
<?php
// DESCRIPTION: REST API controller for managing stored contact messages.
// Provides list, get, and delete endpoints for the admin messages UI.

namespace WeDevs\WeDocs\API;

use WP_Error;
use WP_REST_Server;

/**
* Messages REST API controller.
*
* @since WEDOCS_SINCE
*/
class MessagesApi extends \WP_REST_Controller {

/**
* Post Type Base.
*
* @since WEDOCS_SINCE
*
* @var string
*/
protected $base = 'docs/messages';

/**
* WP Version Number.
*
* @since WEDOCS_SINCE
*
* @var string
*/
protected $version = '2';

/**
* WP Version Slug.
*
* @since WEDOCS_SINCE
*
* @var string
*/
protected $namespace = 'wp/v';

/**
* Parent API class.
*
* @since WEDOCS_SINCE
*
* @var \WeDevs\WeDocs\API
*/
protected $api;

/**
* Initialize the class.
*
* @since WEDOCS_SINCE
*
* @param \WeDevs\WeDocs\API $api Parent API instance.
*/
public function __construct( $api ) {
$this->api = $api;
}

/**
* Register messages API routes.
*
* @since WEDOCS_SINCE
*
* @return void
*/
public function register_api() {
register_rest_route( $this->namespace . $this->version, '/' . $this->base,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'admin_permissions_check' ),
'args' => array(
'page' => array(
'type' => 'integer',
'default' => 1,
'sanitize_callback' => 'absint',
),
'per_page' => array(
'type' => 'integer',
'default' => 20,
'sanitize_callback' => 'absint',
Comment on lines +79 to +87

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reject page=0 / per_page=0 at the route boundary.

absint still allows 0. In get_items(), Line 190 can produce a negative offset for page=0, and Line 229 divides by $per_page, so per_page=0 can crash this endpoint.

Proposed change
                         'page' => array(
                             'type'              => 'integer',
                             'default'           => 1,
                             'sanitize_callback' => 'absint',
+                            'validate_callback' => static function( $value ) {
+                                return (int) $value >= 1;
+                            },
                         ),
                         'per_page' => array(
                             'type'              => 'integer',
                             'default'           => 20,
                             'sanitize_callback' => 'absint',
+                            'validate_callback' => static function( $value ) {
+                                $value = (int) $value;
+                                return $value >= 1 && $value <= 100;
+                            },
                         ),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
'page' => array(
'type' => 'integer',
'default' => 1,
'sanitize_callback' => 'absint',
),
'per_page' => array(
'type' => 'integer',
'default' => 20,
'sanitize_callback' => 'absint',
'page' => array(
'type' => 'integer',
'default' => 1,
'sanitize_callback' => 'absint',
'validate_callback' => static function( $value ) {
return (int) $value >= 1;
},
),
'per_page' => array(
'type' => 'integer',
'default' => 20,
'sanitize_callback' => 'absint',
'validate_callback' => static function( $value ) {
$value = (int) $value;
return $value >= 1 && $value <= 100;
},
),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@includes/API/MessagesApi.php` around lines 79 - 87, Reject zero values at the
route boundary for the request arguments defined in MessagesApi::get_item_schema
or the route args used by MessagesApi::get_items, since absint still permits 0.
Update the validation for page and per_page so values less than 1 are rejected
before reaching the endpoint logic, preventing the negative offset in
get_items() and the divide-by-zero case when per_page is used in the paging
calculations. Use the existing page and per_page argument definitions in
MessagesApi as the place to enforce this constraint.

),
'search' => array(
'type' => 'string',
'default' => '',
'sanitize_callback' => 'sanitize_text_field',
),
'source' => array(
'type' => 'string',
'default' => '',
'enum' => array( '', 'modal', 'widget' ),
'sanitize_callback' => 'sanitize_text_field',
),
),
),
)
);

register_rest_route( $this->namespace . $this->version, '/' . $this->base . '/(?P<id>[\d]+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'admin_permissions_check' ),
),
array(
'methods' => WP_REST_Server::DELETABLE,
'callback' => array( $this, 'delete_item' ),
'permission_callback' => array( $this, 'admin_permissions_check' ),
),
)
);
}

/**
* Check admin permissions.
*
* @since WEDOCS_SINCE
*
* @param \WP_REST_Request $request Full data about the request.
*
* @return bool|WP_Error
*/
public function admin_permissions_check( $request ) {
if ( ! current_user_can( 'manage_options' ) ) {
return new WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to do that.', 'wedocs' ),
array( 'status' => rest_authorization_required_code() )
);
}

return true;
}

/**
* Check if the messages table exists in the database.
*
* @since WEDOCS_SINCE
*
* @return bool|WP_Error True if table exists, WP_Error if not.
*/
private function check_table_exists() {
global $wpdb;

$table_name = $wpdb->prefix . 'wedocs_messages';
$table_exists = $wpdb->get_var(
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name )
);

if ( ! $table_exists ) {
return new WP_Error(
'rest_table_not_found',
__( 'The messages database table does not exist. Please deactivate and reactivate the weDocs plugin to create it.', 'wedocs' ),
array( 'status' => 500 )
);
}

return true;
}

/**
* Get a paginated list of messages.
*
* @since WEDOCS_SINCE
*
* @param \WP_REST_Request $request Full data about the request.
*
* @return \WP_REST_Response|WP_Error
*/
public function get_items( $request ) {
$table_check = $this->check_table_exists();
if ( is_wp_error( $table_check ) ) {
return $table_check;
}

global $wpdb;

$table_name = $wpdb->prefix . 'wedocs_messages';
$page = $request->get_param( 'page' );
$per_page = $request->get_param( 'per_page' );
$search = $request->get_param( 'search' );
$source = $request->get_param( 'source' );
$offset = ( $page - 1 ) * $per_page;

$where_clauses = array();
$where_values = array();

if ( ! empty( $search ) ) {
$like = '%' . $wpdb->esc_like( $search ) . '%';
$where_clauses[] = '(name LIKE %s OR email LIKE %s OR subject LIKE %s OR message LIKE %s)';
$where_values[] = $like;
$where_values[] = $like;
$where_values[] = $like;
$where_values[] = $like;
}

if ( ! empty( $source ) ) {
$where_clauses[] = 'source = %s';
$where_values[] = $source;
}

$where = '';
if ( ! empty( $where_clauses ) ) {
$where = 'WHERE ' . implode( ' AND ', $where_clauses );
}

// Get total count.
$count_query = "SELECT COUNT(*) FROM $table_name $where";
if ( ! empty( $where_values ) ) {
$count_query = $wpdb->prepare( $count_query, $where_values );
}
$total = (int) $wpdb->get_var( $count_query );

// Get paginated results.
$query = "SELECT * FROM $table_name $where ORDER BY submitted_at DESC LIMIT %d OFFSET %d";
$query_values = array_merge( $where_values, array( $per_page, $offset ) );
$messages = $wpdb->get_results( $wpdb->prepare( $query, $query_values ) );

return rest_ensure_response( array(
'messages' => $messages,
'total' => $total,
'total_pages' => ceil( $total / $per_page ),
) );
}

/**
* Get a single message by ID.
*
* @since WEDOCS_SINCE
*
* @param \WP_REST_Request $request Full data about the request.
*
* @return \WP_REST_Response|WP_Error
*/
public function get_item( $request ) {
$table_check = $this->check_table_exists();
if ( is_wp_error( $table_check ) ) {
return $table_check;
}

global $wpdb;

$table_name = $wpdb->prefix . 'wedocs_messages';
$id = (int) $request['id'];

$message = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table_name WHERE id = %d", $id ) );

if ( ! $message ) {
return new WP_Error(
'rest_message_not_found',
__( 'Message not found.', 'wedocs' ),
array( 'status' => 404 )
);
}

return rest_ensure_response( $message );
}

/**
* Delete a single message by ID.
*
* @since WEDOCS_SINCE
*
* @param \WP_REST_Request $request Full data about the request.
*
* @return \WP_REST_Response|WP_Error
*/
public function delete_item( $request ) {
$table_check = $this->check_table_exists();
if ( is_wp_error( $table_check ) ) {
return $table_check;
}

global $wpdb;

$table_name = $wpdb->prefix . 'wedocs_messages';
$id = (int) $request['id'];

$deleted = $wpdb->delete( $table_name, array( 'id' => $id ), array( '%d' ) );

if ( ! $deleted ) {
return new WP_Error(
'rest_message_not_found',
__( 'Message not found.', 'wedocs' ),
array( 'status' => 404 )
);
}

return rest_ensure_response( array(
'success' => true,
'message' => __( 'Message deleted successfully.', 'wedocs' ),
) );
}
}
5 changes: 5 additions & 0 deletions includes/Admin/Menu.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ public function add_admin_submenu() {
$this->capability,
'edit-tags.php?taxonomy=doc_tag&post_type=docs',
),
array(
__( 'Messages', 'wedocs' ),
$this->capability,
$base . '#/messages',
),
Comment on lines +81 to +85

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Align the Messages menu capability with the REST controller.

This entry inherits wedocs_get_publish_cap(), but the PR stack says the new MessagesApi endpoints are admin-only. That means non-admin publishers can see #/messages, open the screen, and hit 403s on every fetch/delete. Please gate the submenu/route with the same capability as the API, or relax the API if those users are meant to manage messages.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@includes/Admin/Menu.php` around lines 81 - 85, The Messages submenu in
Admin/Menu.php is using $this->capability, which lets non-admin publishers open
the route even though MessagesApi is admin-only. Update the Messages menu entry
to use the same capability check as the REST controller, or adjust MessagesApi
if publishers are supposed to manage messages, so the submenu/route and API
permissions stay consistent.

array(
__( 'Settings', 'wedocs' ),
apply_filters( 'wedocs_settings_management_capabilities', $this->capability ),
Expand Down
12 changes: 12 additions & 0 deletions includes/Ajax.php
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,18 @@ public function handle_contact() {
wp_send_json_error(__('Please provide the message details.', 'wedocs'));
}

$email_to = wedocs_get_general_settings( 'email_to', get_option( 'admin_email' ) );

do_action( 'wedocs_before_send_contact_email', [
'name' => $name,
'email' => $email,
'subject' => $subject,
'message' => $message,
'doc_id' => $doc_id,
'recipients' => $email_to,
'source' => 'modal',
] );
Comment on lines +233 to +241

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Enforce GDPR consent on the server before storing/sending.

The consent check is currently described in the frontend layer, but this endpoint still accepts direct POSTs and immediately persists/sends the message. That makes the checkbox trivial to bypass with a manual request.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@includes/Ajax.php` around lines 233 - 241, The contact-email endpoint still
processes direct POST requests without verifying GDPR consent server-side, so
the frontend checkbox can be bypassed. Add a backend consent validation in the
Ajax/contact handler before any persistence or mail sending, and reject the
request if consent is missing. Update the flow around the
wedocs_before_send_contact_email action and the contact submission processing so
the check happens before the message is stored or dispatched.


wedocs_doc_feedback_email($doc_id, $name, $email, $subject, $message);

wp_send_json_success(__('Thanks for your feedback.', 'wedocs'));
Expand Down
1 change: 1 addition & 0 deletions includes/Assets.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ public function register() {
'dokan_active' => is_plugin_active( 'dokan-lite/dokan.php' ),
'upgradePopupContent' => wedocs_get_upgrade_popup_content(),
'siteUrl' => home_url( '/' ),
'gdpr' => wedocs_get_gdpr_frontend_settings(),
),
);
}
Expand Down
Loading
Loading