guard() first: check_ajax_referer() + current_user_can('manage_options') before any request data is read. // phpcs:disable WordPress.Security.ValidatedSanitizedInput -- the flagged reads are boolean-ish flags strict-compared against literal strings ('1'/'true'/'on'), never stored or output raw; everything that is persisted goes through sanitize_text_field()/esc_url_raw() with wp_unslash(). class AffKeeper_Ajax { const NONCE = 'affkeeper_manage'; private static $instance = null; public static function instance() { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } public function hooks() { add_action( 'wp_ajax_affkeeper_create_link', array( $this, 'create' ) ); add_action( 'wp_ajax_affkeeper_update_link', array( $this, 'update' ) ); add_action( 'wp_ajax_affkeeper_delete_link', array( $this, 'delete' ) ); } /** * Shared guard: valid nonce + capability, or a JSON error and stop. */ private function guard() { if ( ! check_ajax_referer( self::NONCE, 'nonce', false ) ) { wp_send_json_error( array( 'message' => __( 'Security check failed — reload the page.', 'affkeeper' ) ), 403 ); } if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to manage links.', 'affkeeper' ) ), 403 ); } } /** * Turn a WP_Error into a JSON error using its own HTTP status where present. * * @param WP_Error $err Error. */ private function fail( $err ) { $data = $err->get_error_data(); $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 400; wp_send_json_error( array( 'message' => $err->get_error_message() ), $status ); } public function create() { $this->guard(); $dest = isset( $_POST['destination_url'] ) ? esc_url_raw( wp_unslash( $_POST['destination_url'] ) ) : ''; if ( '' === $dest ) { wp_send_json_error( array( 'message' => __( 'A destination URL is required.', 'affkeeper' ) ), 400 ); } // "This site" picked in the connected create form: the link lives HERE (local redirect, // local clicks) and is pushed up right away so the account monitors it from minute one. if ( AffKeeper_API::is_configured() && ! empty( $_POST['local'] ) ) { $create_data = array( 'destination' => $dest, 'slug' => isset( $_POST['slug'] ) ? sanitize_text_field( wp_unslash( $_POST['slug'] ) ) : '', 'title' => isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( $_POST['title'] ) ) : '', 'is_affiliate' => ! empty( $_POST['is_affiliate'] ), 'source_url' => isset( $_POST['source_url'] ) ? esc_url_raw( wp_unslash( $_POST['source_url'] ) ) : '', ); if ( isset( $_POST['monitored'] ) ) { $create_data['monitored'] = ( '1' === (string) $_POST['monitored'] ); } $dupe = empty( $_POST['slug'] ) ? AffKeeper_Local::recent_duplicate( $dest ) : null; if ( $dupe ) { wp_send_json_success( array( 'id' => (int) $dupe->id ) ); // double-submit: reuse, don't twin } $id = AffKeeper_Local::create( $create_data ); if ( is_wp_error( $id ) ) { $this->fail( $id ); } $row = AffKeeper_Local::get( (int) $id ); if ( $row && ! empty( $row->monitored ) ) { $pushed = AffKeeper_Sync::push_one( $row ); // best-effort; the hourly sync re-pushes if it failed if ( 'quota' === $pushed ) { // The redirect works — but the account can't monitor it. Surface that on the // next page load instead of silently pretending it's covered. set_transient( 'affkeeper_create_warning', __( 'Link created — it redirects from this site, but it is NOT monitored: your plan\'s monitored-link limit is reached.', 'affkeeper' ), 120 ); } } wp_send_json_success( array( 'id' => $id ) ); } // No account connected → save to the local store (free tier). The link redirects from this // site's own domain and counts clicks locally; nothing leaves the server. if ( ! AffKeeper_API::is_configured() ) { $dupe = empty( $_POST['slug'] ) ? AffKeeper_Local::recent_duplicate( $dest ) : null; if ( $dupe ) { wp_send_json_success( array( 'id' => (int) $dupe->id ) ); // double-submit: reuse, don't twin } $id = AffKeeper_Local::create( array( 'destination' => $dest, 'slug' => isset( $_POST['slug'] ) ? sanitize_text_field( wp_unslash( $_POST['slug'] ) ) : '', 'title' => isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( $_POST['title'] ) ) : '', 'is_affiliate' => ! empty( $_POST['is_affiliate'] ), 'source_url' => isset( $_POST['source_url'] ) ? esc_url_raw( wp_unslash( $_POST['source_url'] ) ) : '', ) ); if ( is_wp_error( $id ) ) { $this->fail( $id ); } wp_send_json_success( array( 'id' => $id ) ); } $payload = array( 'destination_url' => $dest, 'is_affiliate' => ! empty( $_POST['is_affiliate'] ), ); $slug = isset( $_POST['slug'] ) ? sanitize_text_field( wp_unslash( $_POST['slug'] ) ) : ''; if ( '' !== $slug ) { $payload['slug'] = $slug; } $source = isset( $_POST['source_url'] ) ? esc_url_raw( wp_unslash( $_POST['source_url'] ) ) : ''; if ( '' !== $source ) { $payload['source_url'] = $source; } $domain = isset( $_POST['domain_id'] ) ? sanitize_text_field( wp_unslash( $_POST['domain_id'] ) ) : ''; if ( '' !== $domain ) { $payload['domain_id'] = $domain; } $result = AffKeeper_API::create_link( $payload ); if ( is_wp_error( $result ) ) { $this->fail( $result ); } wp_send_json_success( $result ); } public function update() { $this->guard(); $id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : ''; $local_id = isset( $_POST['local_id'] ) ? absint( $_POST['local_id'] ) : 0; // An unsynced local link (plan limit) has no remote id — local_id alone is enough. if ( '' === $id && $local_id <= 0 ) { wp_send_json_error( array( 'message' => __( 'Missing link id.', 'affkeeper' ) ), 400 ); } // Local (free) tier: the id IS the local row id. if ( ! AffKeeper_API::is_configured() ) { $this->apply_local_update( (int) $id ); wp_send_json_success(); } // Connected + a synced link (source='wordpress'): it lives here, so edit the local row (the // redirect) and mirror the monitoring-relevant fields up to the account's record ($id = remote // id). Editing it in the panel instead would leave the real redirect stale. if ( $local_id > 0 ) { // A stale tab can carry ids of rows that were re-created since (rename/delete cycles) — // without this check the slug validation excludes the wrong row and 409s confusingly. if ( ! AffKeeper_Local::get( $local_id ) ) { wp_send_json_error( array( 'message' => __( 'This link changed in the meantime — reload the page and try again.', 'affkeeper' ) ), 409 ); } $this->apply_local_update( $local_id ); // Monitor toggle transitions: off → free the plan slot (drop the account record); // on → claim one (push). The redirect itself is untouched either way. if ( isset( $_POST['monitored'] ) ) { $fresh_l = AffKeeper_Local::get( $local_id ); $mon_on = ( '1' === (string) $_POST['monitored'] ); if ( $fresh_l && ! $mon_on && ! empty( $fresh_l->remote_id ) ) { AffKeeper_API::delete_link( $fresh_l->remote_id ); // best-effort AffKeeper_Local::update( $local_id, array( 'remote_id' => '' ) ); wp_send_json_success(); } if ( $fresh_l && $mon_on && empty( $fresh_l->remote_id ) ) { $pushed = AffKeeper_Sync::push_one( $fresh_l ); if ( 'quota' === $pushed ) { set_transient( 'affkeeper_create_warning', __( 'Monitoring could not be enabled: your plan\'s monitored-link limit is reached.', 'affkeeper' ), 120 ); } wp_send_json_success(); } } $pdata = array(); if ( isset( $_POST['destination_url'] ) ) { $pdata['destination_url'] = esc_url_raw( wp_unslash( $_POST['destination_url'] ) ); } if ( isset( $_POST['is_affiliate'] ) ) { $pdata['is_affiliate'] = ( '1' === (string) $_POST['is_affiliate'] || 'true' === (string) $_POST['is_affiliate'] ); } if ( '' !== $id ) { // This site is authoritative for the short URL: always report it (covers slug // renames) and confirm — a WP-side edit overrides any pending panel request. $fresh = AffKeeper_Local::get( $local_id ); if ( $fresh ) { $pdata['external_url'] = AffKeeper_Local::short_url( $fresh ); } $pdata['wp_synced'] = true; AffKeeper_API::update_link( $id, $pdata ); // best-effort; the local row is the source of truth } wp_send_json_success(); } $payload = array(); if ( isset( $_POST['destination_url'] ) ) { $payload['destination_url'] = esc_url_raw( wp_unslash( $_POST['destination_url'] ) ); } if ( isset( $_POST['source_url'] ) ) { $payload['source_url'] = esc_url_raw( wp_unslash( $_POST['source_url'] ) ); } // Sent as a string (empty = clear the alias); only forwarded when present so an edit that // doesn't touch the slug leaves it alone. if ( isset( $_POST['custom_slug'] ) ) { $payload['custom_slug'] = sanitize_text_field( wp_unslash( $_POST['custom_slug'] ) ); } // Booleans only when explicitly sent, so an edit form can PATCH a subset. if ( isset( $_POST['is_affiliate'] ) ) { $payload['is_affiliate'] = ( '1' === (string) $_POST['is_affiliate'] || 'true' === (string) $_POST['is_affiliate'] ); } if ( isset( $_POST['is_active'] ) ) { $payload['is_active'] = ( '1' === (string) $_POST['is_active'] || 'true' === (string) $_POST['is_active'] ); } $result = AffKeeper_API::update_link( $id, $payload ); if ( is_wp_error( $result ) ) { $this->fail( $result ); } wp_send_json_success( $result ); } public function delete() { $this->guard(); $id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : ''; $local_id = isset( $_POST['local_id'] ) ? absint( $_POST['local_id'] ) : 0; // An unsynced local link (plan limit) has no remote id — local_id alone is enough. if ( '' === $id && $local_id <= 0 ) { wp_send_json_error( array( 'message' => __( 'Missing link id.', 'affkeeper' ) ), 400 ); } // Local (free) tier: the id IS the local row id. if ( ! AffKeeper_API::is_configured() ) { if ( ! AffKeeper_Local::delete( (int) $id ) ) { wp_send_json_error( array( 'message' => __( 'Could not delete the link.', 'affkeeper' ) ), 400 ); } wp_send_json_success(); } // Connected + a synced link: remove BOTH the local redirect and the account's monitoring // record, so nothing is left half-deleted (a dead redirect or an orphaned monitor). if ( $local_id > 0 ) { AffKeeper_Local::delete( $local_id ); if ( '' !== $id ) { AffKeeper_API::delete_link( $id ); // best-effort } wp_send_json_success(); } $result = AffKeeper_API::delete_link( $id ); if ( is_wp_error( $result ) ) { $this->fail( $result ); } wp_send_json_success(); } /** * Apply the edit form's fields to a local link row. Sends a JSON error (and stops) on a slug * clash or DB failure; returns on success. Shared by the free-tier edit and the synced-link edit. * * @param int $local_id Local row id. */ private function apply_local_update( $local_id ) { $data = array(); if ( isset( $_POST['destination_url'] ) ) { $data['destination'] = esc_url_raw( wp_unslash( $_POST['destination_url'] ) ); } if ( isset( $_POST['title'] ) ) { $data['title'] = sanitize_text_field( wp_unslash( $_POST['title'] ) ); } if ( isset( $_POST['source_url'] ) ) { $data['source_url'] = esc_url_raw( wp_unslash( $_POST['source_url'] ) ); } if ( isset( $_POST['is_affiliate'] ) ) { $data['is_affiliate'] = ( '1' === (string) $_POST['is_affiliate'] || 'true' === (string) $_POST['is_affiliate'] ); } if ( isset( $_POST['monitored'] ) ) { $data['monitored'] = ( '1' === (string) $_POST['monitored'] ); } // The edit form sends the alias as custom_slug; the local store calls it slug. $slug = null; if ( isset( $_POST['custom_slug'] ) ) { $slug = sanitize_title( wp_unslash( $_POST['custom_slug'] ) ); } elseif ( isset( $_POST['slug'] ) ) { $slug = sanitize_title( wp_unslash( $_POST['slug'] ) ); } if ( null !== $slug && '' !== $slug ) { if ( AffKeeper_Local::slug_exists( $slug, (int) $local_id ) ) { $holder = AffKeeper_Local::get_by_slug( $slug ); wp_send_json_error( array( 'message' => sprintf( /* translators: 1: the slug, 2: id of the row using it, 3: id the form edited. */ __( 'Slug "%1$s" is already used by another link (#%2$s; you were editing #%3$s). Reload the page and try again.', 'affkeeper' ), $slug, $holder ? (int) $holder->id : 0, (int) $local_id ) ), 409 ); } $data['slug'] = $slug; } if ( ! AffKeeper_Local::update( (int) $local_id, $data ) ) { wp_send_json_error( array( 'message' => __( 'Could not update the link.', 'affkeeper' ) ), 400 ); } } }