plugin_name = $plugin_name; $this->version = $version; } /** * Check whether the current admin screen belongs to Linkmora. * * @since 1.0.0 * * @param string $hook_suffix Optional admin page hook suffix. * @return bool */ private function linkmora_is_admin_page( $hook_suffix = '' ) { if ( isset( $_GET['page'] ) && is_scalar( $_GET['page'] ) ) { $page = sanitize_key( wp_unslash( (string) $_GET['page'] ) ); return in_array( $page, array( 'linkmora-links', 'linkmora-add-link', 'linkmora-settings' ), true ); } return strpos( (string) $hook_suffix, 'linkmora' ) !== false; } /** * Enqueue admin styles for Linkmora screens. * * @since 1.0.0 * * @param string $hook_suffix Optional admin page hook suffix. */ public function enqueue_styles( $hook_suffix = '' ) { if ( ! $this->linkmora_is_admin_page( $hook_suffix ) ) { return; } wp_enqueue_style( $this->plugin_name, plugin_dir_url( __FILE__ ) . 'css/linkmora-admin.css', array(), $this->version, 'all' ); wp_style_add_data( $this->plugin_name, 'rtl', true ); wp_enqueue_style( 'linkmora-modern', plugin_dir_url( __FILE__ ) . 'css/linkmora-modern.css', array( $this->plugin_name ), $this->version, 'all' ); if ( $this->use_jalali_admin_dates() ) { wp_enqueue_style( 'persian-date-picker', plugin_dir_url( __FILE__ ) . 'css/persianDatepicker-default.css', array(), $this->version, 'all' ); } } /** * Register the JavaScript for the admin area. * * @since 1.0.0 * * @param string $hook_suffix Optional admin page hook suffix. */ public function enqueue_scripts( $hook_suffix = '' ) { if ( ! $this->linkmora_is_admin_page( $hook_suffix ) ) { return; } wp_enqueue_script( 'linkmora-admin', plugin_dir_url( __FILE__ ) . 'js/linkmora-admin.js', array( 'jquery', 'jquery-ui-sortable' ), $this->version, false ); if ( $this->use_jalali_admin_dates() ) { wp_enqueue_script( 'persian-date-picker', plugin_dir_url( __FILE__ ) . 'js/persian-datepicker.min.js', array( 'jquery' ), $this->version, false ); } $this_translate_array = array( 'deleteConfirmMsg' => __( 'Are you sure you want to delete this link?', 'linkmora' ), 'bulkDeleteConfirmMsg' => __( 'Are you sure you want to delete the selected links?', 'linkmora' ), 'copySuccess' => __( 'Shortcode copied.', 'linkmora' ), 'copyError' => __( 'Could not copy the shortcode.', 'linkmora' ), 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'tablePageUrl' => admin_url( 'admin.php' ), 'reorderNonce' => wp_create_nonce( 'linkmora_reorder' ), 'tableNonce' => wp_create_nonce( 'linkmora_table' ), 'tableError' => __( 'The links table could not be updated. Please try again.', 'linkmora' ), 'isLinksPage' => isset( $_GET['page'] ) && is_scalar( $_GET['page'] ) && sanitize_key( wp_unslash( (string) $_GET['page'] ) ) === 'linkmora-links', 'useJalaliDates' => $this->use_jalali_admin_dates(), ); wp_localize_script( 'linkmora-admin', 'linkmoraJsTranslate', $this_translate_array ); /** * Fires after Core admin assets are enqueued on a Linkmora screen. * * @since 2.5.0 * * @param string $hook_suffix Current admin page hook suffix. * @param string $version Linkmora Core version. */ do_action( 'linkmora_admin_assets', $hook_suffix, $this->version ); } /** * Register the Linkmora admin menu and submenu pages. * * @since 1.0.0 */ public function add_admin_menu() { add_menu_page( __( 'Linkmora', 'linkmora' ), __( 'Linkmora', 'linkmora' ), 'manage_options', 'linkmora-links', array( $this, 'linkmora_table_callback' ), 'dashicons-admin-links' ); $links_hook = add_submenu_page( 'linkmora-links', __( 'Links', 'linkmora' ), __( 'Links', 'linkmora' ), 'manage_options', 'linkmora-links', array( $this, 'linkmora_table_callback' ), ); add_action( "load-$links_hook", function () { add_screen_option( 'per_page', array( 'label' => __( 'Links per page', 'linkmora' ), 'default' => 25, 'option' => 'linkmora_per_page', ) ); } ); add_submenu_page( 'linkmora-links', __( 'Add Link', 'linkmora' ), __( 'Add Link', 'linkmora' ), 'manage_options', 'linkmora-add-link', array( $this, 'linkmora_add_link_callback' ), ); add_submenu_page( 'linkmora-links', __( 'Linkmora Settings', 'linkmora' ), __( 'Settings', 'linkmora' ), 'manage_options', 'linkmora-settings', array( $this, 'linkmora_settings_callback' ), ); } /** * Shared header (icon, title, tab navigation) rendered at the top of * every Linkmora admin page, for a consistent look. * * @since 1.9.0 * * @param string $active Active tab key. */ private function render_admin_page_header( $active ) { $tabs = array( 'links' => array( 'label' => __( 'Links', 'linkmora' ), 'icon' => 'admin-links', 'url' => admin_url( 'admin.php?page=linkmora-links' ), ), 'add-link' => array( 'label' => __( 'Add Link', 'linkmora' ), 'icon' => 'plus-alt2', 'url' => admin_url( 'admin.php?page=linkmora-add-link' ), ), 'settings' => array( 'label' => __( 'Settings', 'linkmora' ), 'icon' => 'admin-generic', 'url' => admin_url( 'admin.php?page=linkmora-settings' ), ), ); ?>

version ); ?>


' . $label . ''; } return '' . $label . ''; } /** * Render a disabled setting row that advertises a Pro-only capability. * * @since 2.6.1 * * @param string $label The setting label. * @param string $description The setting description. * @param string $capability Optional capability key. */ private function render_locked_setting_row( $label, $description, $capability = '' ) { ?>
get_pro_badge_markup( $capability ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Badge markup uses a fixed template with an escaped upgrade URL and static label. ?>

get_pro_badge_markup( 'appearance' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Badge markup uses a fixed template with an escaped upgrade URL and static label. ?>

get_pro_badge_markup( 'analytics' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Badge markup uses a fixed template with an escaped upgrade URL and static label. ?>

use_jalali_admin_dates() ? linkmora_to_persian_digits( $formatted ) : $formatted; } /** * Normalize and validate a submitted link URL. * * @since 2.6.1 * * @param mixed $raw_url Raw URL value to normalize. * @return string Normalized URL or empty string if invalid. */ private function normalize_link_url( $raw_url ) { if ( ! is_string( $raw_url ) && ! is_numeric( $raw_url ) ) { return ''; } $url = trim( (string) $raw_url ); if ( '' === $url ) { return ''; } if ( strpos( $url, '//' ) === 0 ) { $url = 'https:' . $url; } elseif ( ! preg_match( '/^[a-z][a-z0-9+.-]*:\/\//i', $url ) ) { $url = 'https://' . $url; } $scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) ); if ( ! in_array( $scheme, array( 'http', 'https' ), true ) ) { return ''; } $url = esc_url_raw( $url, array( 'http', 'https' ) ); $host = wp_parse_url( $url, PHP_URL_HOST ); return empty( $host ) ? '' : $url; } /** * Normalize a submitted link date into a usable ISO date string. * * @since 2.0.0 * * @param mixed $raw_date Submitted date value to normalize. * @return string Normalized date in Y-m-d format or empty string if invalid. */ private function normalize_submitted_link_date( $raw_date ) { if ( ! is_string( $raw_date ) && ! is_numeric( $raw_date ) ) { return ''; } $raw_date = $this->normalize_date_digits( trim( sanitize_text_field( (string) $raw_date ) ) ); if ( '' === $raw_date ) { return ''; } if ( $this->use_jalali_admin_dates() ) { $parts = preg_split( '/[\/\-]/', $raw_date ); if ( count( $parts ) === 3 && (int) $parts[0] < 1700 ) { return $this->normalize_jalali_date( $parts ); } $normalized_gregorian = $this->normalize_ymd_date( str_replace( '/', '-', $raw_date ) ); if ( '' !== $normalized_gregorian ) { return $normalized_gregorian; } return ''; } $normalized_gregorian = $this->normalize_ymd_date( str_replace( '/', '-', $raw_date ) ); return '' !== $normalized_gregorian ? $normalized_gregorian : ''; } /** * Normalize a Jalali date to ISO Y-m-d format. * * @since 2.0.0 * * @param array $parts Jalali date parts as [year, month, day]. * @return string Normalized date in Y-m-d format or empty string if invalid. */ private function normalize_jalali_date( $parts ) { if ( count( $parts ) !== 3 ) { return ''; } $jalali_year = (int) $parts[0]; $jalali_month = (int) $parts[1]; $jalali_day = (int) $parts[2]; if ( ! $this->is_valid_jalali_date( $jalali_year, $jalali_month, $jalali_day ) ) { return ''; } $parts = \Linkmora\Vendor\Jalaali\Jalaali::toGregorian( $jalali_year, $jalali_month, $jalali_day ); $gregorian = sprintf( '%04d-%02d-%02d', $parts['gy'], $parts['gm'], $parts['gd'] ); return $this->normalize_ymd_date( $gregorian ); } /** * Normalize Persian and Arabic digits to ASCII digits. * * @since 2.0.0 * * @param string $date Date string containing localized digits. * @return string Date string with Persian/Arabic digits converted to ASCII digits. */ private function normalize_date_digits( $date ) { return strtr( $date, array( '۰' => '0', '۱' => '1', '۲' => '2', '۳' => '3', '۴' => '4', '۵' => '5', '۶' => '6', '۷' => '7', '۸' => '8', '۹' => '9', '٠' => '0', '١' => '1', '٢' => '2', '٣' => '3', '٤' => '4', '٥' => '5', '٦' => '6', '٧' => '7', '٨' => '8', '٩' => '9', ) ); } /** * Normalize a date string to ISO Y-m-d format. * * @since 2.0.0 * * @param string $date Date string to normalize. * @return string Normalized date in Y-m-d format or empty string if invalid. */ private function normalize_ymd_date( $date ) { $parts = preg_split( '/[\/\-]/', (string) $date ); if ( count( $parts ) !== 3 ) { return ''; } $year = (int) $parts[0]; $month = (int) $parts[1]; $day = (int) $parts[2]; if ( ! checkdate( $month, $day, $year ) ) { return ''; } return sprintf( '%04d-%02d-%02d', $year, $month, $day ); } /** * Validate whether a Jalali date is within the supported range. * * @since 2.0.0 * * @param int $year The Jalali year. * @param int $month The Jalali month. * @param int $day The Jalali day. * @return bool Whether the date is valid. */ private function is_valid_jalali_date( $year, $month, $day ) { if ( $year < 1200 || $year > 1600 || $month < 1 || $month > 12 || $day < 1 ) { return false; } return \Linkmora\Vendor\Jalaali\Jalaali::isValidJalaaliDate( $year, $month, $day ); } /** * Render the add/edit link admin form and populate data when editing an existing link. * * @since 2.0.0 */ public function linkmora_add_link_callback() { $form_action = 'insert'; if ( isset( $_GET['id'] ) && is_scalar( $_GET['id'] ) && ! empty( $_GET['id'] ) ) { $link_id = absint( wp_unslash( $_GET['id'] ) ); global $wpdb; $table_name = esc_sql( $wpdb->prefix . 'linkmora_links' ); // The table name uses the trusted WordPress prefix; the row ID is prepared. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read current custom-table data with a trusted identifier and prepared value. $link_data = $wpdb->get_row( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The trusted table identifier cannot be a prepared value; the row ID is prepared. $wpdb->prepare( "SELECT * FROM $table_name WHERE id = %d", $link_id ) ); $form_action = 'update'; } $use_jalali_dates = $this->use_jalali_admin_dates(); $start_date_value = isset( $link_data ) ? $this->format_link_date_for_input( $link_data->start_date ) : ''; $end_date_value = isset( $link_data ) ? $this->format_link_date_for_input( $link_data->end_date ) : ''; $start_date_display_value = isset( $link_data ) ? $this->format_link_date_for_display( $link_data->start_date ) : ''; $end_date_display_value = isset( $link_data ) ? $this->format_link_date_for_display( $link_data->end_date ) : ''; $publish_date = isset( $link_data ) ? linkmora_format_admin_date( $link_data->start_date ) : __( 'Not published yet.', 'linkmora' ); $expiry_date = isset( $link_data ) ? linkmora_format_admin_date( $link_data->end_date ) : '-'; require 'partials/add-link-display.php'; } /** * Persist the "Links per page" Screen Option when saved. * * @since 2.1.0 * * @param mixed $status The current status/value for the option. * @param string $option The option name being updated. * @param mixed $value The submitted value for the option. * @return mixed */ public function save_screen_options( $status, $option, $value ) { if ( 'linkmora_per_page' === $option ) { return absint( $value ); } return $status; } /** * Render the settings page container. */ public function linkmora_settings_callback() { require 'partials/settings-display.php'; } /** * Register the settings sections owned by Core. * * @since 2.5.0 * * @param array> $sections Registered sections. * @param string $tab_id Current tab ID. * @return array> */ public function register_current_settings_sections( $sections, $tab_id ) { if ( 'admin' === $tab_id ) { $sections['general'] = array( 'label' => __( 'Admin Settings', 'linkmora' ) ); } if ( 'appearance' === $tab_id && ! $this->is_pro_available( 'appearance' ) ) { $sections['discover'] = array( 'label' => __( 'Appearance', 'linkmora' ) ); } return $sections; } /** * Render current settings fields through the public settings action. * * @since 2.5.0 * * @param string $tab_id Current tab ID. * @param string $section_id Current section ID. * @param array $context Settings rendering context. */ public function render_current_settings_fields( $tab_id, $section_id, $context ) { $request_key = $context['request_key']; $options = $context['options']; $linkmora_toggle_row = $context['linkmora_toggle_row']; $linkmora_number_field = $context['linkmora_number_field']; $linkmora_color_field = $context['linkmora_color_field']; $linkmora_quad_field = $context['linkmora_quad_field']; $trbl = $context['trbl']; $trbl_labels = $context['trbl_labels']; $corners = $context['corners']; $corner_labels = $context['corner_labels']; $strip_px = $context['strip_px']; require 'partials/settings-fields.php'; } /** * AJAX handler: persist a drag-and-drop reorder of the links list. * * @since 2.1.0 */ public function ajax_save_link_order() { check_ajax_referer( 'linkmora_reorder', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error(); } $posted = isset( $_POST ) && is_array( $_POST ) ? wp_unslash( $_POST ) : array(); if ( ! empty( $posted['s'] ) || ! empty( $posted['orderby'] ) || ( ! empty( $posted['status_filter'] ) && 'all' !== $posted['status_filter'] ) ) { wp_send_json_error( array( 'message' => __( 'Clear filters before reordering backlinks.', 'linkmora' ) ), 400 ); } $ordered_ids = array(); if ( isset( $posted['order'] ) && is_array( $posted['order'] ) ) { foreach ( $posted['order'] as $raw_id ) { if ( is_scalar( $raw_id ) && absint( $raw_id ) > 0 ) { $ordered_ids[] = absint( $raw_id ); } } } if ( empty( $ordered_ids ) ) { wp_send_json_error(); } $list_table = new Linkmora_Table(); $per_page = $list_table->get_current_per_page(); $paged = isset( $posted['paged'] ) && is_scalar( $posted['paged'] ) ? max( 1, absint( $posted['paged'] ) ) : 1; $start_position = ( $paged - 1 ) * $per_page; $changed = $list_table->save_dragged_order( $ordered_ids, $start_position ); if ( $changed && function_exists( 'linkmora_clear_rocket_cache' ) ) { linkmora_clear_rocket_cache(); } wp_send_json_success(); } /** * AJAX handler: render only the prepared Links table component. * * The normal page callback and this endpoint share the same renderer and * Linkmora_Table query path so PHP remains the source of truth. * * @since 2.5.0 */ public function ajax_render_links_table() { check_ajax_referer( 'linkmora_table', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to manage backlinks.', 'linkmora' ) ), 403 ); } $list_table = new Linkmora_Table(); $list_table->prepare_items(); ob_start(); $this->render_links_table_component( $list_table ); $html = ob_get_clean(); wp_send_json_success( array( 'html' => $html ) ); } /** * Save handler for the plugin's own Settings page. * * @since 1.9.0 */ public function linkmora_save_settings() { if ( ! isset( $_POST['linkmora_settings_submit'] ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { return; } check_admin_referer( 'linkmora_settings_nonce', 'linkmora_settings_nonce' ); $request_key = 'linkmora_settings'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The unslashed input is immediately passed through known or explicitly registered per-key sanitizers. $submitted = isset( $_POST[ $request_key ] ) && is_array( $_POST[ $request_key ] ) ? wp_unslash( $_POST[ $request_key ] ) : array(); $posted = linkmora_sanitize_submitted_settings( $submitted ); if ( is_wp_error( $posted ) ) { $this->redirect_after_invalid_settings(); } $switches = array( 'linkmora_enable_bulk_delete', 'linkmora_enable_detailed_status', 'linkmora_enable_status_date', 'linkmora_enable_expiry_notice', 'linkmora_delete_data_on_uninstall', 'linkmora_open_links_new_tab', ); $data = array(); foreach ( $switches as $key ) { $data[ $key ] = ! empty( $posted[ $key ] ); } /** * Filter sanitized settings before they are routed to Core or Pro storage. * * @since 2.5.0 * @since 2.8.1 The second argument contains sanitized known settings and * explicitly registered extension settings, not raw request data. * Extensions that previously sanitized raw values from this * argument should register those keys with * linkmora_setting_sanitizers instead. Submitted or returned * extension settings without a valid sanitizer reject the save. * * @param array $data Sanitized settings. * @param array $posted Sanitized submitted settings. * @param array $existing Existing effective settings. */ $data = apply_filters( 'linkmora_sanitize_settings', $data, $posted, linkmora_get_settings() ); $data = linkmora_validate_filtered_settings( $data ); if ( is_wp_error( $data ) ) { $this->redirect_after_invalid_settings(); } $core_changes = array(); $pro_changes = array(); foreach ( $data as $key => $value ) { if ( linkmora_get_setting_scope( $key, $value ) === 'pro' ) { $pro_changes[ $key ] = $value; } else { $core_changes[ $key ] = $value; } } if ( $core_changes ) { linkmora_update_core_options( $core_changes ); } if ( $pro_changes ) { linkmora_update_pro_options( $pro_changes ); } if ( function_exists( 'linkmora_schedule_cron_jobs' ) ) { linkmora_schedule_cron_jobs(); } $active_tab = isset( $_POST['linkmora_settings_active_tab'] ) && is_scalar( $_POST['linkmora_settings_active_tab'] ) ? sanitize_key( wp_unslash( $_POST['linkmora_settings_active_tab'] ) ) : ''; $redirect = admin_url( 'admin.php?page=linkmora-settings¬ice=saved' ); if ( $active_tab ) { $redirect .= '#' . rawurlencode( $active_tab ); } wp_safe_redirect( $redirect ); exit(); } /** * Reject a settings save when an extension setting cannot be validated. * * @since 2.8.1 */ private function redirect_after_invalid_settings() { wp_safe_redirect( admin_url( 'admin.php?page=linkmora-settings¬ice=invalid' ) ); exit(); } /** * Handle GET/POST row actions (edit redirect, delete, reorder) for the * links list page. * * IMPORTANT: this must run on 'admin_init' (before WP prints any admin * HTML), not from inside the page render callback. Running it from the * render callback meant wp_redirect() (used for the Edit action) was * called after admin-header.php had already sent output, so the * redirect silently failed and the user was left on a blank page. * * @since 1.1.0 */ public function handle_link_list_actions() { $get = isset( $_GET ) && is_array( $_GET ) ? wp_unslash( $_GET ) : array(); $post = isset( $_POST ) && is_array( $_POST ) ? wp_unslash( $_POST ) : array(); if ( ! is_admin() || ! isset( $get['page'] ) || ! is_scalar( $get['page'] ) || sanitize_key( $get['page'] ) !== 'linkmora-links' ) { return; } if ( ! current_user_can( 'manage_options' ) ) { return; } if ( isset( $get['linkmora_export_csv'] ) ) { $this->export_links_csv(); } if ( isset( $post['linkmora_import_submit'] ) ) { $this->import_links_csv(); } // Edit: just a redirect, not a state change, so no nonce is required. if ( isset( $get['action'], $get['link_id'] ) && 'edit' === $get['action'] && is_scalar( $get['link_id'] ) ) { wp_safe_redirect( admin_url( 'admin.php?page=linkmora-add-link&id=' . absint( $get['link_id'] ) ) ); exit(); } $list_table = new Linkmora_Table(); if ( isset( $get['action'], $get['link_id'] ) && 'delete' === $get['action'] && is_scalar( $get['link_id'] ) ) { check_admin_referer( 'linkmora_link_action' ); $link_id = absint( $get['link_id'] ); $list_table->delete_link( $link_id ); wp_safe_redirect( admin_url( 'admin.php?page=linkmora-links¬ice=deleted' ) ); exit(); } if ( isset( $_SERVER['REQUEST_METHOD'] ) && 'post' === sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) && isset( $post['bulk-item-selection'] ) && is_array( $post['bulk-item-selection'] ) ) { $bulk_action = $list_table->current_action(); if ( 'bulk-delete' === $bulk_action && linkmora_feature_enabled( 'linkmora_enable_bulk_delete' ) ) { check_admin_referer( 'bulk-links' ); foreach ( $post['bulk-item-selection'] as $raw_id ) { if ( is_scalar( $raw_id ) ) { $list_table->delete_link( absint( $raw_id ) ); } } wp_safe_redirect( admin_url( 'admin.php?page=linkmora-links¬ice=deleted' ) ); exit(); } } } /** * Export all (non-history) links as a downloadable CSV file. * * @since 2.4.0 */ public function export_links_csv() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to export backlinks.', 'linkmora' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'linkmora_export_csv' ); global $wpdb; $table_name = esc_sql( $wpdb->prefix . 'linkmora_links' ); // CSV export needs current custom-table rows; the table name uses the trusted WordPress prefix. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- No request values are interpolated and no stale cache is appropriate for a download. $links = $wpdb->get_results( "SELECT * FROM $table_name WHERE status <> 'history' ORDER BY link_order ASC", ARRAY_A ); $links = is_array( $links ) ? $links : array(); $columns = array( 'title' => 'title', 'link_url' => 'link_url', 'follow' => 'follow', 'display_option' => 'display_option', 'start_date' => 'start_date', 'end_date' => 'end_date', 'status' => 'status', ); /** * Filter columns included in a backlinks CSV export. * * @since 2.5.0 * * @param array $columns Database keys mapped to CSV headers. */ $filtered_columns = apply_filters( 'linkmora_csv_export_columns', $columns ); if ( is_array( $filtered_columns ) ) { $valid_columns = array(); foreach ( $filtered_columns as $key => $label ) { if ( ( ! is_string( $key ) && ! is_int( $key ) ) || ! is_scalar( $label ) ) { continue; } $key = sanitize_key( (string) $key ); $label = sanitize_text_field( (string) $label ); if ( '' !== $key && '' !== $label ) { $valid_columns[ $key ] = $label; } } if ( $valid_columns ) { $columns = $valid_columns; } } // This is an HTTP response stream, not a filesystem path. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Opening php://output is required to stream the CSV response. $output = fopen( 'php://output', 'w' ); if ( false === $output ) { wp_die( esc_html__( 'The CSV export could not be started.', 'linkmora' ) ); } nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename=linkmora-' . gmdate( 'Y-m-d' ) . '.csv' ); $write_failed = fputcsv( $output, array_map( 'linkmora_csv_escape_cell', array_values( $columns ) ), ',', '"', '' ) === false; foreach ( $links as $link ) { if ( $write_failed ) { break; } if ( ! is_array( $link ) ) { continue; } $row = array(); foreach ( $columns as $key => $header ) { $row[ $key ] = isset( $link[ $key ] ) && is_scalar( $link[ $key ] ) ? $link[ $key ] : ''; } /** * Filter one prepared CSV export row. * * @since 2.5.0 * * @param array $row Ordered export values keyed by field. * @param array $link Full backlink row. * @param array $columns Export column definitions. */ $row = apply_filters( 'linkmora_csv_export_row', $row, $link, $columns ); $row = is_array( $row ) ? $row : array(); $ordered_row = array(); foreach ( array_keys( $columns ) as $key ) { $value = isset( $row[ $key ] ) && is_scalar( $row[ $key ] ) ? $row[ $key ] : ''; $ordered_row[] = linkmora_csv_escape_cell( $value ); } $write_failed = fputcsv( $output, $ordered_row, ',', '"', '' ) === false; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the php://output HTTP response stream, not a filesystem file. fclose( $output ); if ( $write_failed ) { exit(); } exit(); } /** * Import links from an uploaded CSV file. Expects the same columns * export_links_csv() produces (title, link_url are required; the * rest are optional and get sensible defaults). * * @since 2.4.0 */ public function import_links_csv() { check_admin_referer( 'linkmora_import_csv', 'linkmora_import_csv_nonce' ); $upload = array(); if ( isset( $_FILES['linkmora_import_file'] ) && is_array( $_FILES['linkmora_import_file'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Preserve the upload temp path; error code and is_uploaded_file() are checked before it is read. $upload = $_FILES['linkmora_import_file']; } $tmp_name = isset( $upload['tmp_name'] ) && is_string( $upload['tmp_name'] ) ? $upload['tmp_name'] : ''; if ( ! isset( $upload['error'] ) || UPLOAD_ERR_OK !== $upload['error'] || '' === $tmp_name || ! is_uploaded_file( $tmp_name ) ) { wp_safe_redirect( admin_url( 'admin.php?page=linkmora-links&import_notice=error' ) ); exit(); } $filtered_max_file_size = apply_filters( 'linkmora_csv_import_max_size', 1024 * 1024 ); $max_file_size = is_scalar( $filtered_max_file_size ) && is_numeric( $filtered_max_file_size ) ? max( 1, (int) $filtered_max_file_size ) : 1024 * 1024; // The path is a verified PHP upload temporary file. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_filesize -- Read the byte size of the validated upload before CSV parsing. $file_size = filesize( $tmp_name ); if ( false === $file_size || $file_size > $max_file_size ) { wp_safe_redirect( admin_url( 'admin.php?page=linkmora-links&import_notice=file_too_large' ) ); exit(); } // The path is an uploaded temporary file validated with is_uploaded_file(). // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Stream access is required to parse the HTTP-uploaded CSV. $handle = fopen( $tmp_name, 'r' ); if ( ! $handle ) { wp_safe_redirect( admin_url( 'admin.php?page=linkmora-links&import_notice=error' ) ); exit(); } $header = fgetcsv( $handle, 0, ',', '"', '' ); if ( ! $header ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the validated upload stream. fclose( $handle ); wp_safe_redirect( admin_url( 'admin.php?page=linkmora-links&import_notice=error' ) ); exit(); } $header = array_map( 'sanitize_key', array_map( 'trim', $header ) ); // Accept click_count from Phase 1 and Pro exports, but never import it: // Core owns and preserves the count already stored for each backlink. $allowed_headers = array( 'title', 'link_url', 'follow', 'display_option', 'start_date', 'end_date', 'status', 'click_count' ); /** * Filter CSV headers accepted by the importer. * * @since 2.5.0 * * @param string[] $allowed_headers Accepted lowercase header names. */ $filtered_headers = apply_filters( 'linkmora_csv_import_columns', $allowed_headers ); $allowed_headers = array(); if ( is_array( $filtered_headers ) ) { foreach ( $filtered_headers as $allowed_header ) { if ( is_string( $allowed_header ) ) { $allowed_header = sanitize_key( $allowed_header ); if ( '' !== $allowed_header ) { $allowed_headers[] = $allowed_header; } } } } $required_headers = array( 'title', 'link_url' ); if ( ! $allowed_headers || count( $header ) !== count( array_unique( $header ) ) || array_diff( $required_headers, $header ) || array_diff( $header, $allowed_headers ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the validated upload stream. fclose( $handle ); wp_safe_redirect( admin_url( 'admin.php?page=linkmora-links&import_notice=invalid_header' ) ); exit(); } global $wpdb; $table_name = esc_sql( $wpdb->prefix . 'linkmora_links' ); $current_date = linkmora_current_date(); // The table identifier is built only from WordPress's configured prefix; no request data enters this query. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Import ordering uses a fresh aggregate over the custom table and a trusted identifier. $max_order = absint( $wpdb->get_var( "SELECT COALESCE(MAX(link_order), 0) FROM $table_name WHERE status = 'active'" ) ); $imported = 0; $skipped = 0; $invalid = 0; while ( ( $row = fgetcsv( $handle, 0, ',', '"', '' ) ) !== false ) { if ( count( $row ) < count( $header ) ) { ++$skipped; continue; } $data = array_combine( $header, array_map( 'linkmora_csv_unescape_cell', array_slice( $row, 0, count( $header ) ) ) ); /** * Filter a raw CSV row before Core sanitization and persistence. * * @since 2.5.0 * * @param array $data CSV row keyed by header. * @param array $header Validated CSV header. */ $data = apply_filters( 'linkmora_csv_import_row', $data, $header ); if ( ! is_array( $data ) ) { ++$skipped; continue; } $title = isset( $data['title'] ) && is_scalar( $data['title'] ) ? sanitize_text_field( (string) $data['title'] ) : ''; $link_url_value = isset( $data['link_url'] ) && is_scalar( $data['link_url'] ) ? trim( (string) $data['link_url'] ) : ''; if ( '' === $title || '' === $link_url_value ) { ++$skipped; continue; } $link_url = $this->normalize_link_url( $link_url_value ); if ( empty( $link_url ) ) { ++$invalid; continue; } // Match the existing table column capacities before inserting, so // permissive database modes cannot silently truncate imported values. $title_length = mb_strlen( $title, 'UTF-8' ); $link_url_length = mb_strlen( $link_url, 'UTF-8' ); if ( $title_length > 255 || $link_url_length > 512 ) { ++$invalid; continue; } $follow = isset( $data['follow'] ) && is_scalar( $data['follow'] ) ? sanitize_key( (string) $data['follow'] ) : 'follow'; if ( ! array_key_exists( $follow, linkmora_get_allowed_link_types() ) ) { ++$invalid; continue; } $display_option = isset( $data['display_option'] ) && is_scalar( $data['display_option'] ) ? sanitize_text_field( (string) $data['display_option'] ) : 'all'; if ( ! in_array( $display_option, array( 'all', 'home', 'inner' ), true ) ) { $display_option = 'all'; } $start_date = ! empty( $data['start_date'] ) ? $this->normalize_submitted_link_date( $data['start_date'] ) : $current_date; $end_date = ! empty( $data['end_date'] ) ? $this->normalize_submitted_link_date( $data['end_date'] ) : linkmora_relative_date( '+30 days' ); if ( '' === $start_date || '' === $end_date || strtotime( $end_date ) <= strtotime( $start_date ) ) { ++$invalid; continue; } $status = ( strtotime( $current_date ) >= strtotime( $start_date ) ) && ( strtotime( $current_date ) < strtotime( $end_date ) ) ? 'active' : 'inactive'; $link_order = null; if ( 'active' === $status ) { ++$max_order; $link_order = $max_order; } $inserted = $wpdb->insert( $table_name, array( 'title' => $title, 'link_url' => $link_url, 'follow' => $follow, 'display_option' => $display_option, 'link_order' => $link_order, 'start_date' => $start_date, 'end_date' => $end_date, 'status' => $status, 'status_changed_at' => $current_date, ) ); if ( false !== $inserted ) { $insert_id = intval( $wpdb->insert_id ); ++$imported; $imported_data = array_merge( $data, compact( 'title', 'link_url', 'follow', 'display_option', 'start_date', 'end_date', 'status', 'link_order' ) ); /** This action is documented in add_backlinks(). */ do_action( 'linkmora_after_backlink_created', $insert_id, $imported_data, 'csv_import' ); /** * Fires after Core imports and persists one CSV row. * * @since 2.5.0 * * @param int $insert_id New backlink ID. * @param array $imported_data Sanitized and raw imported values. */ do_action( 'linkmora_after_csv_import_row', $insert_id, $imported_data ); } else { ++$invalid; } } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the validated upload stream. fclose( $handle ); if ( $imported > 0 && function_exists( 'linkmora_clear_rocket_cache' ) ) { linkmora_clear_rocket_cache(); } wp_safe_redirect( admin_url( 'admin.php?page=linkmora-links&import_notice=' . ( $imported > 0 ? 'success' : 'error' ) . '&count=' . $imported . '&skipped=' . $skipped . '&invalid=' . $invalid ) ); exit(); } /** * Render dashboard panels backed by existing plugin functionality. * * @since 2.5.0 */ private function render_dashboard_sidebar() { $export_url = wp_nonce_url( admin_url( 'admin.php?page=linkmora-links&linkmora_export_csv=1' ), 'linkmora_export_csv' ); ?> '; echo '
' . esc_html__( 'Updating links...', 'linkmora' ) . '
'; echo ''; // Keep one native form around the search, bulk actions, and pagination so // every interaction continues to work when JavaScript is unavailable. echo ''; echo ''; } /** * Render the admin links table page and success/error notices. */ public function linkmora_table_callback() { $query = isset( $_GET ) && is_array( $_GET ) ? wp_unslash( $_GET ) : array(); $list_table = new Linkmora_Table(); echo '
'; $this->render_admin_page_header( 'links' ); if ( isset( $query['notice'] ) && is_scalar( $query['notice'] ) ) { $notice_messages = array( 'update' => __( 'Link updated successfully!', 'linkmora' ), 'insert' => __( 'Link inserted successfully!', 'linkmora' ), 'deleted' => __( 'Link deleted successfully!', 'linkmora' ), 'reordered' => __( 'Link order updated successfully!', 'linkmora' ), ); $notice_key = sanitize_key( (string) $query['notice'] ); if ( isset( $notice_messages[ $notice_key ] ) ) { echo '

' . esc_html( $notice_messages[ $notice_key ] ) . '

'; } } $list_table->prepare_items(); if ( isset( $query['import_notice'] ) && is_scalar( $query['import_notice'] ) ) { $import_key = sanitize_key( (string) $query['import_notice'] ); if ( 'success' === $import_key ) { $count = isset( $query['count'] ) && is_scalar( $query['count'] ) ? absint( $query['count'] ) : 0; $skipped = isset( $query['skipped'] ) && is_scalar( $query['skipped'] ) ? absint( $query['skipped'] ) : 0; $invalid = isset( $query['invalid'] ) && is_scalar( $query['invalid'] ) ? absint( $query['invalid'] ) : 0; echo '

' . esc_html( sprintf( /* translators: 1: imported row count, 2: skipped row count, 3: invalid row count */ __( '%1$d imported / %2$d skipped / %3$d invalid.', 'linkmora' ), $count, $skipped, $invalid ) ) . '

'; } elseif ( 'invalid_header' === $import_key ) { echo '

' . esc_html__( 'The CSV header is invalid. Use the exported CSV format and include title and link_url columns.', 'linkmora' ) . '

'; } elseif ( 'file_too_large' === $import_key ) { echo '

' . esc_html__( 'The CSV file is too large to import.', 'linkmora' ) . '

'; } elseif ( 'error' === $import_key ) { $count = isset( $query['count'] ) && is_scalar( $query['count'] ) ? absint( $query['count'] ) : 0; $skipped = isset( $query['skipped'] ) && is_scalar( $query['skipped'] ) ? absint( $query['skipped'] ) : 0; $invalid = isset( $query['invalid'] ) && is_scalar( $query['invalid'] ) ? absint( $query['invalid'] ) : 0; if ( $skipped || $invalid ) { echo '

' . esc_html( sprintf( /* translators: 1: imported row count, 2: skipped row count, 3: invalid row count */ __( '%1$d imported / %2$d skipped / %3$d invalid.', 'linkmora' ), $count, $skipped, $invalid ) ) . '

'; } else { echo '

' . esc_html__( 'The CSV file could not be imported. Please check the file and try again.', 'linkmora' ) . '

'; } } } /** * Fires above the links table for registered dashboard/statistics cards. * * @since 2.5.0 * * @param Linkmora_Table $list_table Prepared links list table. */ do_action( 'linkmora_admin_dashboard_widgets', $list_table ); echo '
'; echo '
'; echo ''; echo '
'; $this->render_dashboard_sidebar(); echo '
'; echo '
'; } /** * WP-Cron only runs when something visits the site (and many hosts * disable it entirely in favour of a real server cron hitting * wp-cron.php on a schedule the site owner has to configure). If that * never happens, the scheduled expiry/notification/deletion events can * sit due for a long time without ever firing. As a safety net, run the * same cleanup directly on any wp-admin page load, rate-limited to * once an hour so it stays cheap. * * @since 1.3.0 */ public function maybe_run_scheduled_cleanup() { if ( get_transient( 'linkmora_last_cleanup_run' ) ) { return; } set_transient( 'linkmora_last_cleanup_run', 1, HOUR_IN_SECONDS ); $linkmora_options = linkmora_get_settings(); if ( function_exists( 'linkmora_deactivate_expired_links' ) ) { linkmora_deactivate_expired_links(); } /** * Fires during the hourly admin fallback after Core status sync runs. * * @since 2.5.0 * * @param array $linkmora_options Effective settings. */ do_action( 'linkmora_run_automation_fallback', $linkmora_options ); } /** * Show a dashboard notice (on the plugin's own admin screens) listing * backlinks that are about to expire, as a visible complement to the * email notification -- some admins don't check the admin email inbox. * * @since 1.2.0 */ public function show_expiry_admin_notice() { if ( ! linkmora_feature_enabled( 'linkmora_enable_expiry_notice' ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { return; } $screen = get_current_screen(); if ( ! $screen || strpos( $screen->id, 'linkmora' ) === false ) { return; } global $wpdb; $table_name = esc_sql( $wpdb->prefix . 'linkmora_links' ); $soon = linkmora_relative_date( '+1 day' ); $effective_status = linkmora_get_link_status_sql(); // The custom table name and status expression come from trusted internal values; request dates remain prepared. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query current custom-table rows using a trusted status expression and prepared values. $expiring = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name and status SQL expression are trusted internals; request values are prepared. $wpdb->prepare( "SELECT id, title, end_date FROM $table_name WHERE ($effective_status) = %s AND end_date <= %s", 'active', $soon ) ); if ( empty( $expiring ) ) { return; } // Identifies this specific notice (which links are expiring) so a // dismissal keeps suppressing it as long as that same set of links // is what's flagged -- it only reappears once the set actually // changes (a link gets renewed/removed, or a new one starts // expiring). $expiring_ids = wp_list_pluck( $expiring, 'id' ); sort( $expiring_ids ); $notice_id = md5( implode( ',', $expiring_ids ) ); $dismissed = get_user_meta( get_current_user_id(), 'linkmora_dismissed_expiry_notice', true ); if ( $dismissed === $notice_id ) { return; } $titles = wp_list_pluck( $expiring, 'title' ); /* translators: 1: number of expiring backlinks, 2: backlink titles. */ $notice_format = _n( '%1$d backlink is about to expire (within a day): %2$s', '%1$d backlinks are about to expire (within a day): %2$s', count( $expiring ), 'linkmora' ); printf( '

', esc_attr( $notice_id ), esc_attr( wp_create_nonce( 'linkmora_dismiss_expiry_notice' ) ) ); printf( esc_html( $notice_format ), count( $expiring ), esc_html( implode( ', ', $titles ) ) ); echo '

'; } /** * AJAX handler: persist the dismissal of the expiry notice for the * current admin user so it doesn't reappear on the next page load. * * @since 1.4.0 */ public function ajax_dismiss_expiry_notice() { check_ajax_referer( 'linkmora_dismiss_expiry_notice', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error(); } $notice_id = isset( $_POST['notice_id'] ) && is_scalar( $_POST['notice_id'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['notice_id'] ) ) : ''; update_user_meta( get_current_user_id(), 'linkmora_dismissed_expiry_notice', $notice_id ); wp_send_json_success(); } /** * Process a submitted backlink form, either inserting or updating a row. * * Validates the nonce and user capability before persisting the normalized * backlink data, including its URL, follow type, and active date range. * * @since 1.0.0 */ public function add_backlinks() { $submitted_nonce = isset( $_POST['linkmora_add_link_nonce'] ) && is_scalar( $_POST['linkmora_add_link_nonce'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['linkmora_add_link_nonce'] ) ) : ''; if ( isset( $_POST['submit_backlinks'] ) && wp_verify_nonce( $submitted_nonce, 'linkmora_add_link_nonce' ) ) { if ( ! current_user_can( 'manage_options' ) ) { return; } $operation = isset( $_POST['action'] ) && is_scalar( $_POST['action'] ) ? sanitize_key( wp_unslash( (string) $_POST['action'] ) ) : ''; if ( ! in_array( $operation, array( 'insert', 'update' ), true ) ) { return; } $link_id = isset( $_POST['link_id'] ) && is_scalar( $_POST['link_id'] ) ? absint( wp_unslash( $_POST['link_id'] ) ) : 0; $current_link = null; global $wpdb; $table_name = esc_sql( $wpdb->prefix . 'linkmora_links' ); if ( 'update' === $operation ) { // The table name uses the trusted WordPress prefix; the row ID is prepared. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read current custom-table data with a trusted identifier and prepared value. $current_link = $wpdb->get_row( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The trusted table identifier cannot be a prepared value; the row ID is prepared. $wpdb->prepare( "SELECT * FROM $table_name WHERE id = %d", $link_id ) ); if ( ! $current_link ) { add_settings_error( 'my_messages', 'backlink_update_error', __( 'Error updating link: link not found.', 'linkmora' ), 'error' ); return; } } $title = isset( $_POST['linkmora_link_title'] ) && is_scalar( $_POST['linkmora_link_title'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['linkmora_link_title'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- normalize_link_url() restricts HTTP(S), sanitizes the URL, and requires a host. $link_url = $this->normalize_link_url( isset( $_POST['linkmora_link_url'] ) && is_scalar( $_POST['linkmora_link_url'] ) ? wp_unslash( (string) $_POST['linkmora_link_url'] ) : '' ); $raw_follow = isset( $_POST['linkmora_link_follow'] ) && is_scalar( $_POST['linkmora_link_follow'] ) ? sanitize_key( wp_unslash( (string) $_POST['linkmora_link_follow'] ) ) : ''; $allowed_link_types = linkmora_get_allowed_link_types(); if ( '' === $raw_follow ) { $raw_follow = 'update' === $operation && $current_link ? (string) $current_link->follow : 'follow'; } $preserve_existing_type = 'update' === $operation && $current_link && ! array_key_exists( $current_link->follow, $allowed_link_types ) && $raw_follow === $current_link->follow; $follow = $preserve_existing_type ? $current_link->follow : $raw_follow; if ( ! $preserve_existing_type && ! array_key_exists( $follow, $allowed_link_types ) ) { add_settings_error( 'my_messages', 'backlink_link_type_error', __( 'The selected link type is not currently available.', 'linkmora' ), 'error' ); return; } if ( '' === $link_url ) { add_settings_error( 'my_messages', 'backlink_url_error', __( 'Please enter a valid website URL.', 'linkmora' ), 'error' ); return; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The normalizer sanitizes input and accepts only valid Gregorian or Jalali dates. $start_date = $this->normalize_submitted_link_date( isset( $_POST['linkmora_link_start_date'] ) && is_scalar( $_POST['linkmora_link_start_date'] ) ? wp_unslash( (string) $_POST['linkmora_link_start_date'] ) : '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The normalizer sanitizes input and accepts only valid Gregorian or Jalali dates. $end_date = $this->normalize_submitted_link_date( isset( $_POST['linkmora_link_end_date'] ) && is_scalar( $_POST['linkmora_link_end_date'] ) ? wp_unslash( (string) $_POST['linkmora_link_end_date'] ) : '' ); // End date is exclusive: the link expires once current date reaches end_date. if ( '' === $start_date || '' === $end_date || strtotime( $end_date ) <= strtotime( $start_date ) ) { add_settings_error( 'my_messages', 'backlink_date_error', __( 'Please enter valid start and end dates. The end date must be after the start date.', 'linkmora' ), 'error' ); return; } $display_option = isset( $_POST['linkmora_link_display_option'] ) && is_scalar( $_POST['linkmora_link_display_option'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['linkmora_link_display_option'] ) ) : 'all'; if ( ! in_array( $display_option, array( 'all', 'home', 'inner' ), true ) ) { $display_option = 'all'; } $backlink_data = linkmora_sanitize_backlink_data( array( 'title' => $title, 'link_url' => $link_url, 'follow' => $follow, 'display_option' => $display_option, 'start_date' => $start_date, 'end_date' => $end_date, ), $operation, array( 'linkmora_link_title' => $title, 'linkmora_link_url' => $link_url, 'linkmora_link_follow' => $follow, 'linkmora_link_display_option' => $display_option, 'linkmora_link_start_date' => $start_date, 'linkmora_link_end_date' => $end_date, ) ); $title = sanitize_text_field( $backlink_data['title'] ?? '' ); $link_url = $this->normalize_link_url( $backlink_data['link_url'] ?? '' ); $follow = sanitize_key( $backlink_data['follow'] ?? 'follow' ); $display_option = sanitize_key( $backlink_data['display_option'] ?? 'all' ); $start_date = $this->normalize_submitted_link_date( $backlink_data['start_date'] ?? '' ); $end_date = $this->normalize_submitted_link_date( $backlink_data['end_date'] ?? '' ); $preserves_stored_type = 'update' === $operation && $current_link && ! array_key_exists( $current_link->follow, linkmora_get_allowed_link_types() ) && $follow === $current_link->follow; if ( '' === $link_url || ( ! array_key_exists( $follow, linkmora_get_allowed_link_types() ) && ! $preserves_stored_type ) || ! in_array( $display_option, array( 'all', 'home', 'inner' ), true ) || '' === $start_date || '' === $end_date || strtotime( $end_date ) <= strtotime( $start_date ) ) { add_settings_error( 'my_messages', 'backlink_extension_validation_error', __( 'The backlink data is invalid.', 'linkmora' ), 'error' ); return; } $backlink_data = array_merge( $backlink_data, compact( 'title', 'link_url', 'follow', 'display_option', 'start_date', 'end_date' ) ); $validation_errors = linkmora_validate_backlink_data( new WP_Error(), $backlink_data, $operation ); if ( $validation_errors->has_errors() ) { add_settings_error( 'my_messages', 'backlink_extension_validation_error', $validation_errors->get_error_message(), 'error' ); return; } $current_date = linkmora_current_date(); $saved_id = 0; $previous_link = null; if ( 'insert' === $operation ) { $notice = 'insert'; // The table name is trusted and the aggregate query contains no request values. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Obtain fresh ordering from the custom table. $link_order = $wpdb->get_var( "SELECT COALESCE(MAX(link_order), 0) FROM $table_name;" ); ++$link_order; $status = ( strtotime( $current_date ) >= strtotime( $start_date ) ) && ( strtotime( $current_date ) < strtotime( $end_date ) ) ? 'active' : 'inactive'; $link_order = 'active' === $status ? $link_order : null; $result = $wpdb->insert( $table_name, array( 'title' => $title, 'link_url' => $link_url, 'follow' => $follow, 'display_option' => $display_option, 'link_order' => $link_order, 'start_date' => $start_date, 'end_date' => $end_date, 'status' => $status, 'status_changed_at' => $current_date, ) ); $insert_id = $wpdb->insert_id; $saved_id = intval( $insert_id ); // Keep a history row when a backlink is first created. $wpdb->insert( $table_name, array( 'title' => $title, 'link_url' => $link_url, 'follow' => $follow, 'display_option' => $display_option, 'link_order' => null, 'start_date' => $start_date, 'end_date' => $end_date, 'status' => 'history', 'parent_id' => $insert_id, ) ); } if ( 'update' === $operation ) { $notice = 'update'; $id = $link_id; $previous_link = get_object_vars( $current_link ); $saved_id = $id; $was_active = ( 'active' === $current_link->status ); // Use submitted dates so extending an expired link can reactivate it. $status = ( strtotime( $current_date ) >= strtotime( $start_date ) ) && ( strtotime( $current_date ) < strtotime( $end_date ) ) ? 'active' : 'inactive'; $wpdb->insert( $table_name, array( 'title' => $title, 'link_url' => $link_url, 'follow' => $follow, 'display_option' => $display_option, 'link_order' => null, 'start_date' => $start_date, 'end_date' => $end_date, 'status' => 'history', 'parent_id' => $id, ) ); $update_data = array( 'title' => $title, 'link_url' => $link_url, 'follow' => $follow, 'display_option' => $display_option, 'start_date' => $start_date, 'end_date' => $end_date, 'status' => $status, ); if ( 'active' === $status ) { if ( ! $was_active ) { // Was inactive/expired and is now active again: put it // at the end of the current order (it had no order // while inactive). // The table name is trusted and the aggregate query contains no request values. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Obtain fresh ordering from the custom table. $next_order = absint( $wpdb->get_var( "SELECT COALESCE(MAX(link_order), 0) FROM $table_name WHERE status = 'active'" ) ) + 1; $update_data['link_order'] = $next_order; $update_data['status_changed_at'] = $current_date; } } else { $update_data['link_order'] = null; if ( $was_active ) { $update_data['status_changed_at'] = $current_date; } } $result = $wpdb->update( $table_name, $update_data, array( 'id' => $id, ) ); } if ( false !== $result ) { if ( 'insert' === $notice ) { /** * Fires after a backlink and its initial history row are created. * * @since 2.5.0 * * @param int $saved_id New backlink ID. * @param array $backlink_data Sanitized backlink data. * @param string $context Creation context. */ do_action( 'linkmora_after_backlink_created', $saved_id, $backlink_data, 'admin' ); } else { /** * Fires after an existing backlink is updated. * * @since 2.5.0 * * @param int $saved_id Updated backlink ID. * @param array $backlink_data Sanitized backlink data. * @param array $previous_link Backlink row before the update. * @param string $context Update context. */ do_action( 'linkmora_after_backlink_updated', $saved_id, $backlink_data, $previous_link, 'admin' ); } linkmora_clear_rocket_cache(); wp_safe_redirect( admin_url( 'admin.php?page=linkmora-links¬ice=' . rawurlencode( $notice ) ) ); exit(); } else { add_settings_error( 'my_messages', 'backlink_insert_error', __( 'Error adding link.', 'linkmora' ), 'error' ); } } } }