prepare(): values and ID lists as placeholders, table and // column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared // where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins). /** * SettingsPage — renders and saves the Clusterlane Settings admin page. */ class SettingsPage { public function __construct( private \Clusterlane\Core\Plugin $plugin ) {} public function handle_save(): void { $settings = $this->plugin->get('settings'); $this->save( $settings ); } public function render(): void { $settings = $this->plugin->get('settings'); if ( isset( $_GET['settings-updated'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag echo '

' . esc_html__( 'Settings saved.', 'clusterlane' ) . ( isset( $_GET['recomputed'] ) ? ' ' . esc_html__( 'Clusters were recalculated with the new thresholds.', 'clusterlane' ) : '' ) . '

'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag } $post_types = $settings->post_types(); $taxonomies = $settings->taxonomies(); $min_term_count = $settings->min_term_count(); $batch_size = (int) $settings->get( 'batch_size', 25 ); $title_truncation = $settings->title_truncation(); $title_trunc_custom = $settings->title_truncation_custom(); $th = $settings->get( 'thresholds', [] ); $t = wp_parse_args( $th, $this->default_thresholds() ); // Every post type with its own pages on the site: public, or publicly // queryable (some plugins register public => false but still give the // items front-end URLs). Media is left out. (0.98.129) $all_post_types = get_post_types( [ 'public' => true ], 'objects' ) + get_post_types( [ 'publicly_queryable' => true ], 'objects' ); unset( $all_post_types['attachment'] ); uasort( $all_post_types, static fn( $a, $b ) => ( $a->name === 'post' ? -2 : ( $a->name === 'page' ? -1 : 0 ) ) <=> ( $b->name === 'post' ? -2 : ( $b->name === 'page' ? -1 : 0 ) ) ?: strcasecmp( $a->label, $b->label ) ); $all_taxonomies = get_taxonomies( [ 'public' => true, 'show_ui' => true ], 'objects' ); ?>

__( 'Add links to articles', 'clusterlane' ) ]; $write_caps[ \Clusterlane\Core\Capabilities::SEO ] = __( 'Write SEO title & description', 'clusterlane' ); // all editions since 0.98.154 (SEO Titles) $write_roles = []; foreach ( wp_roles()->roles as $slug => $role ) { if ( $slug === 'administrator' ) continue; $rc = $role['capabilities'] ?? []; if ( empty( $rc[ \Clusterlane\Core\Capabilities::USE ] ) && empty( $rc[ \Clusterlane\Core\Capabilities::MANAGE ] ) ) continue; $write_roles[ $slug ] = [ translate_user_role( $role['name'] ), $rc ]; } ?>

[ $rname, $rc ] ): ?> $label ): ?>
aria-label="">

(function() { // ── Collapse state (localStorage) ───────────────────────── document.querySelectorAll('details.clusterlane-settings-section[data-collapse-key]').forEach(function(d) { var k = d.dataset.collapseKey; try { var v = localStorage.getItem(k); if (v === '0') d.open = false; if (v === '1') d.open = true; } catch(e) {} d.addEventListener('toggle', function() { try { localStorage.setItem(k, d.open ? '1' : '0'); } catch(e) {} }); }); // (AI provider/model sync and Test connection: premium, clusterlane_settings_footer.) })(); get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN (" . implode( ',', array_fill( 0, count( $post_types ), '%s' ) ) . ")", $post_types ) ); $titles = $wpdb->get_col( $wpdb->prepare( "SELECT post_title FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN (" . implode( ',', array_fill( 0, count( $post_types ), '%s' ) ) . ") AND ( post_title LIKE %s OR post_title LIKE %s OR post_title LIKE %s OR post_title LIKE %s OR post_title LIKE %s ) ORDER BY post_date DESC LIMIT 3000", array_merge( $post_types, [ '%:%', '%|%', '%' . "\u{2013}" . '%', '%' . "\u{2014}" . '%', '% - %' ] ) ) ); // A few recent titles without a separator too, for a custom pattern. $recent = $wpdb->get_col( $wpdb->prepare( "SELECT post_title FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN (" . implode( ',', array_fill( 0, count( $post_types ), '%s' ) ) . ") ORDER BY post_date DESC LIMIT 300", $post_types ) ); $titles = array_unique( array_merge( (array) $titles, (array) $recent ) ); $titles = array_map( static fn( $t ) => html_entity_decode( wp_strip_all_tags( (string) $t ), ENT_QUOTES, 'UTF-8' ), (array) $titles ); return [ 'titles' => array_values( $titles ), 'total' => $total ]; } /** * The posted settings form (clusterlane[...]), unslashed once and sanitized * field by field (1.3.0). Only fields that were posted are returned, so the * save code can still tell "not on the page" from "turned off". Everything * after this — the core fields below and the premium sections through the * clusterlane_settings_save / clusterlane_settings_saved hooks — sees only * this sanitized copy, never $_POST. * * @return array */ public static function posted(): array { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- the nonce is verified by Admin::handle_settings_save(); every field is sanitized below $in = isset( $_POST['clusterlane'] ) && is_array( $_POST['clusterlane'] ) ? wp_unslash( $_POST['clusterlane'] ) : []; $key = static fn( $v ): string => is_scalar( $v ) ? sanitize_key( (string) $v ) : ''; $text = static fn( $v ): string => is_scalar( $v ) ? sanitize_text_field( (string) $v ) : ''; $long = static fn( $v ): string => is_scalar( $v ) ? \Clusterlane\Core\Settings::clean_text( (string) $v, 20000 ) : ''; // prompts, stop words: plain text kept as typed (control characters removed) $keys = static fn( $v ): array => array_values( array_filter( array_map( $key, (array) $v ) ) ); $schema = [ // Flags (checkboxes and "this section was on the page" markers). 'rendered_cached' => 'flag', 'toolbar_present' => 'flag', 'toolbar' => 'flag', 'show_thumbnails' => 'flag', 'seo_fallback' => 'flag', 'seo_ai_write' => 'flag', 'ai_roles_present' => 'flag', 'ai_api_key_remove' => 'flag', 'pillar_box_present' => 'flag', 'pillar_box' => 'flag', // Choices. 'title_truncation' => 'key', 'seo_source' => 'key', 'seo_ai_suffix_mode' => 'key', 'ai_provider' => 'key', 'ai_tone' => 'key', 'pillar_box_style' => 'key', // Single-line text. 'title_truncation_custom' => 'text', 'seo_title_field' => 'text', 'seo_description_field' => 'text', 'seo_ai_suffix' => 'text', 'seo_ai_language' => 'text', 'ai_model' => 'text', 'ai_model_analysis' => 'text', 'ai_api_key' => 'text', 'pillar_box_text' => 'text', // Numbers. 'min_term_count' => 'int', 'batch_size' => 'int', 'ai_limit_users' => 'int', // Multi-line text. 'tms_stop_words' => 'long', 'seo_ai_instructions' => 'long', 'gallery_shortcodes' => 'textarea', // Lists of slugs. 'post_types' => 'keys', 'rendered_types' => 'keys', 'taxonomies' => 'keys', 'write_caps_present' => 'keys', 'ai_roles' => 'keys', ]; $out = []; foreach ( $schema as $field => $type ) { if ( ! array_key_exists( $field, $in ) ) continue; $v = $in[ $field ]; switch ( $type ) { case 'flag': $out[ $field ] = ! empty( $v ) && ! is_array( $v ); break; case 'key': $out[ $field ] = $key( $v ); break; case 'text': $out[ $field ] = trim( $text( $v ) ); break; case 'int': $out[ $field ] = is_scalar( $v ) ? (int) $v : 0; break; case 'long': $out[ $field ] = $long( $v ); break; case 'textarea': $out[ $field ] = is_scalar( $v ) ? sanitize_textarea_field( (string) $v ) : ''; break; case 'keys': $out[ $field ] = $keys( $v ); break; } } // role => [ capability, … ] if ( isset( $in['write_caps'] ) ) { $out['write_caps'] = []; foreach ( (array) $in['write_caps'] as $role => $caps ) $out['write_caps'][ $key( $role ) ] = $keys( $caps ); } // threshold => whole number; graph setting => number. foreach ( [ 'thresholds' => 'int', 'graph_display' => 'float' ] as $field => $type ) { if ( ! isset( $in[ $field ] ) ) continue; $out[ $field ] = []; foreach ( (array) $in[ $field ] as $k => $v ) { if ( is_scalar( $v ) ) $out[ $field ][ $key( $k ) ] = 'int' === $type ? (int) $v : (float) $v; } } // AI prompts (premium): prompt key => text. if ( isset( $in['prompts'] ) ) { $out['prompts'] = []; foreach ( (array) $in['prompts'] as $k => $v ) $out['prompts'][ $key( $k ) ] = $long( $v ); } // Link Card Templates (premium): markup meant for posts, so it is // filtered like post content (wp_kses_post), not stripped of HTML. if ( isset( $in['link_templates'] ) ) { $out['link_templates'] = []; foreach ( (array) $in['link_templates'] as $row ) { if ( ! is_array( $row ) ) continue; $out['link_templates'][] = [ 'id' => $key( $row['id'] ?? '' ), 'name' => $text( $row['name'] ?? '' ), 'html' => is_scalar( $row['html'] ?? null ) ? wp_kses_post( (string) $row['html'] ) : '', ]; } } return $out; } private function save( \Clusterlane\Core\Settings $settings ): void { $raw = self::posted(); $data = $settings->all(); $data['post_types'] = array_map( fn( $v ) => sanitize_key( $v ), (array) ( $raw['post_types'] ?? ['post'] ) ); // Published pages are read only for scanned post types (0.98.177). $data['rendered_types'] = array_values( array_intersect( array_map( fn( $v ) => sanitize_key( $v ), (array) ( $raw['rendered_types'] ?? [] ) ), $data['post_types'] ) ); $data['rendered_cached'] = ! empty( $raw['rendered_cached'] ); // 0.98.178 // Taxonomies and minimum term count are managed in Cluster Admin now; // keep the stored values unless a (legacy) form still posts them. if ( isset( $raw['taxonomies'] ) ) $data['taxonomies'] = array_map( fn( $v ) => sanitize_key( $v ), (array) $raw['taxonomies'] ); if ( isset( $raw['min_term_count'] ) ) $data['min_term_count'] = min( 100, max( 1, (int) $raw['min_term_count'] ) ); $data['batch_size'] = min( 50, max( 5, (int) ( $raw['batch_size'] ?? 25 ) ) ); // Who can change articles (0.98.132): per role, only the permissions // shown on the page, never administrators. if ( ! empty( $raw['write_caps_present'] ) ) { $present = array_intersect( array_map( 'sanitize_key', (array) $raw['write_caps_present'] ), \Clusterlane\Core\Capabilities::WRITE_CAPS ); $chosen = (array) ( $raw['write_caps'] ?? [] ); foreach ( wp_roles()->role_objects as $slug => $role_obj ) { if ( $slug === 'administrator' ) continue; if ( ! $role_obj->has_cap( \Clusterlane\Core\Capabilities::USE ) && ! $role_obj->has_cap( \Clusterlane\Core\Capabilities::MANAGE ) ) continue; $on = array_map( 'sanitize_key', (array) ( $chosen[ $slug ] ?? [] ) ); foreach ( $present as $cap ) { in_array( $cap, $on, true ) ? $role_obj->add_cap( $cap ) : $role_obj->remove_cap( $cap ); } } } if ( ! empty( $raw['toolbar_present'] ) ) $data['toolbar'] = ! empty( $raw['toolbar'] ); $allowed_trunc = [ 'none', 'colon', 'dash', 'pipe', 'custom' ]; $raw_trunc = sanitize_key( $raw['title_truncation'] ?? 'none' ); $data['title_truncation'] = in_array( $raw_trunc, $allowed_trunc, true ) ? $raw_trunc : 'none'; // Validate custom regex: must start and end with / $raw_custom = sanitize_text_field( $raw['title_truncation_custom'] ?? '' ); $data['show_thumbnails'] = ! empty( $raw['show_thumbnails'] ); $data['title_truncation_custom'] = ( $data['title_truncation'] === 'custom' && preg_match( '/^\/.*\/[gimsuy]*$/', $raw_custom ) ) ? $raw_custom : ''; $raw_t = $raw['thresholds'] ?? []; $thresholds = []; foreach ( $this->default_thresholds() as $key => $default ) { $thresholds[ $key ] = isset( $raw_t[ $key ] ) ? (int) $raw_t[ $key ] : $default; } $data['thresholds'] = $thresholds; $data['graph_display'] = $this->sanitize_graph_display( (array) ( $raw['graph_display'] ?? [] ) ); $data['tms_stop_words'] = \Clusterlane\Core\Settings::clean_text( \Clusterlane\Core\Settings::heal_entities( (string) ( $raw['tms_stop_words'] ?? '' ) ), 20000 ); // posted() unslashed it once — never again, or regex backslashes are lost $data['gallery_shortcodes']= sanitize_textarea_field( $raw['gallery_shortcodes'] ?? '' ); // SEO title & description source (SeoMeta). $data['seo_source'] = ( ( $raw['seo_source'] ?? 'auto' ) === 'custom' ) ? 'custom' : 'auto'; $data['seo_title_field'] = \Clusterlane\Core\SeoMeta::field_name( (string) ( $raw['seo_title_field'] ?? '' ) ) ?: 'seo_title'; $data['seo_description_field'] = \Clusterlane\Core\SeoMeta::field_name( (string) ( $raw['seo_description_field'] ?? '' ) ) ?: 'seo_description'; $data['seo_fallback'] = ! empty( $raw['seo_fallback'] ); // Premium sections (AI settings, prompts, usage limits, Link Card // Templates) sanitize their own fields via this filter. Without them — // the free build, or an edition without those features — the stored // values are simply kept, since $data started from $settings->all(). $data = apply_filters( 'clusterlane_settings_save', $data, $raw, $settings ); // $raw: sanitized by posted() $old_thresholds = (array) $settings->get( 'thresholds', [] ); $settings->save( $data ); \Clusterlane\Core\SeoMeta::reset(); do_action( 'clusterlane_settings_saved', $raw, $settings ); // premium: stores a new AI key (sanitized by posted()) // New thresholds → recalculate archetypes/health now, not "after Compute Metrics". if ( wp_parse_args( $old_thresholds, $this->default_thresholds() ) != $data['thresholds'] ) { $engine = $this->plugin->get( 'cluster_engine' ); if ( $engine ) { $engine->compute_all(); $this->recomputed = true; } } } /** Set by save() when thresholds changed and clusters were recomputed. */ public bool $recomputed = false; /** * SEO field check for the Settings card: the latest article's values and * how many articles have the description field filled (field only — no * excerpt fallback), with the saved settings. * @return array{sample:?array,filled:int,total:int}|null */ private function seo_check( \Clusterlane\Core\Settings $settings ): ?array { global $wpdb; $types = array_values( array_filter( array_map( 'sanitize_key', (array) $settings->post_types() ) ) ) ?: [ 'post' ]; $latest = get_posts( \Clusterlane\Core\Scope::query_args( [ 'post_type' => $types, 'post_status' => 'publish', 'posts_per_page' => 1, 'orderby' => 'date', 'order' => 'DESC' ] ) ); $sample = null; if ( $latest ) { $sample = \Clusterlane\Core\SeoMeta::get( $latest[0] ) + [ 'post_title' => get_the_title( $latest[0] ) ]; } $src = \Clusterlane\Core\SeoMeta::source(); if ( $src['id'] === 'aioseo' ) { // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare(). $row = $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(DISTINCT p.ID) AS total, COUNT(DISTINCT CASE WHEN CHAR_LENGTH(TRIM(seo.description)) > 0 THEN p.ID END) AS filled FROM {$wpdb->posts} p LEFT JOIN {$wpdb->prefix}aioseo_posts seo ON seo.post_id = p.ID WHERE " . \Clusterlane\Core\Scope::sql( 'p' ) . " AND p.post_type IN (" . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ")", $types ) ); // Scope::sql() has no values // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared } elseif ( $src['desc'] ) { // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare(). $row = $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(DISTINCT p.ID) AS total, COUNT(DISTINCT CASE WHEN CHAR_LENGTH(TRIM(seo.meta_value)) > 0 THEN p.ID END) AS filled FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} seo ON seo.post_id = p.ID AND seo.meta_key = %s WHERE " . \Clusterlane\Core\Scope::sql( 'p' ) . " AND p.post_type IN (" . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ")", array_merge( [ $src['desc'] ], $types ) ) ); // Scope::sql() has no values // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared } else { return [ 'sample' => $sample, 'filled' => 0, 'total' => 0 ]; } return [ 'sample' => $sample, 'filled' => (int) ( $row->filled ?? 0 ), 'total' => (int) ( $row->total ?? 0 ) ]; } private function sanitize_graph_display( array $raw ): array { $clamp = fn( $v, $min, $max ) => min( $max, max( $min, (float) $v ) ); return [ 'avoid_overlap' => $clamp( $raw['avoid_overlap'] ?? 1.1, 0.5, 3.0 ), 'spring_length' => $clamp( $raw['spring_length'] ?? 180, 80, 400 ), 'central_gravity' => $clamp( $raw['central_gravity'] ?? 0.12, 0, 0.5 ), 'repulsion' => $clamp( $raw['repulsion'] ?? 320, 50, 800 ), 'iterations' => (int) $clamp( $raw['iterations'] ?? 220, 50, 500 ), 'graph_height' => (int) $clamp( $raw['graph_height'] ?? 420, 300, 800 ), ]; } private function default_thresholds(): array { return [ 'balanced_mesh_entropy' => 80, 'balanced_mesh_gini' => 40, 'authority_pyramid_entropy' => 60, 'authority_pyramid_gini' => 55, 'developed_min_posts' => 12, 'developed_min_score' => 90, 'developed_max_orphan' => 10, 'emerging_max_posts' => 6, 'health_green' => 70, 'health_orange' => 50, ]; } }