' . esc_html__( 'Check my site', 'tedrix' ) . '' ); return $links; } ); /** * Tools -> Tedrix check: the Google and AI check on top, the local titles * check below, and one quiet line about the paid service at the bottom. * Directory guideline 11: upgrade mentions only on the plugin's own page. */ function tedrix_kollen_sida() { if ( ! current_user_can( 'manage_options' ) ) { return; } ?>



·

'sanitize_text_field' ] ); register_setting( 'tedrix', TEDRIX_OPT_SECRET, [ 'sanitize_callback' => 'sanitize_text_field' ] ); } ); function tedrix_settings_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } $site_id = get_option( TEDRIX_OPT_SITEID, '' ); $secret = get_option( TEDRIX_OPT_SECRET, '' ); $endpoint = rest_url( 'tedrix/v1/apply' ); $log = get_option( 'tedrix_apply_log', [] ); ?>

Tedrix

' . esc_html__( 'Connected', 'tedrix' ) . '' : '' . esc_html__( 'Not connected yet', 'tedrix' ) . ''; ?>

'GET', 'callback' => 'tedrix_rest_ping', 'permission_callback' => '__return_true', ] ); /* HMAC-KONTROLLEN LIGGER I PERMISSION_CALLBACK, INTE I CALLBACKEN. * * Den satt förut inne i hanteraren, vilket fungerade men var fel ställe: * WordPress kör permission_callback FÖRE anropet ens tolkas, och verktyg * som Plugin Check läser '__return_true' som en öppen skrivändpunkt. Nu * avvisas en osignerad begäran innan den rör ett enda inlägg. */ register_rest_route( 'tedrix/v1', '/apply', [ 'methods' => 'POST', 'callback' => 'tedrix_rest_apply', 'permission_callback' => 'tedrix_verify', ] ); register_rest_route( 'tedrix/v1', '/revert', [ 'methods' => 'POST', 'callback' => 'tedrix_rest_revert', 'permission_callback' => 'tedrix_verify', ] ); // IndexNow key file. After an apply, Tedrix pings IndexNow so search engines // hear about the change right away; the protocol verifies ownership by // fetching this key from the same host. The key is derived from the shared // secret (never the secret itself) and is useless for anything but IndexNow. register_rest_route( 'tedrix/v1', '/indexnow-key', [ 'methods' => 'GET', 'callback' => 'tedrix_rest_indexnow_key', 'permission_callback' => '__return_true', ] ); } ); /** The IndexNow key: sha256 of the shared secret, first 32 hex chars. Served as * raw text because the IndexNow validator expects the bare key, not JSON. */ function tedrix_indexnow_key() { $secret = (string) get_option( TEDRIX_OPT_SECRET, '' ); return $secret === '' ? '' : substr( hash( 'sha256', $secret ), 0, 32 ); } function tedrix_rest_indexnow_key() { $key = tedrix_indexnow_key(); if ( $key === '' ) { return new WP_Error( 'tedrix_no_secret', 'Site not connected.', [ 'status' => 404 ] ); } header( 'Content-Type: text/plain; charset=utf-8' ); echo $key; // phpcs:ignore WordPress.Security.EscapeOutput -- 32 hex chars, no user input exit; } /* IndexNow scopes ownership by the key file's PATH: a key under /wp-json/ only * validates URLs under /wp-json/, which is never where content lives. So the * key must also be served from the site ROOT (/{key}.txt) to cover the whole * site. Done on `init` without rewrite rules, so it needs no flush and works * on any permalink setup. */ add_action( 'init', function () { $key = tedrix_indexnow_key(); if ( $key === '' ) { return; } $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; $path = trim( (string) wp_parse_url( $uri, PHP_URL_PATH ), '/' ); if ( $path === $key . '.txt' ) { header( 'Content-Type: text/plain; charset=utf-8' ); echo $key; // phpcs:ignore WordPress.Security.EscapeOutput -- 32 hex chars, no user input exit; } } ); /** Verify the request is genuinely from Tedrix: HMAC-SHA256 over "timestamp.body". */ function tedrix_verify( WP_REST_Request $req ) { $secret = (string) get_option( TEDRIX_OPT_SECRET, '' ); if ( $secret === '' ) { return new WP_Error( 'tedrix_no_secret', 'Site not connected.', [ 'status' => 403 ] ); } $ts = (string) $req->get_header( 'x-tedrix-timestamp' ); $sig = (string) $req->get_header( 'x-tedrix-signature' ); if ( $ts === '' || $sig === '' ) { return new WP_Error( 'tedrix_unsigned', 'Missing signature.', [ 'status' => 401 ] ); } if ( abs( time() - (int) $ts ) > TEDRIX_MAX_SKEW ) { return new WP_Error( 'tedrix_stale', 'Signature expired.', [ 'status' => 401 ] ); } $body = $req->get_body(); $expected = hash_hmac( 'sha256', $ts . '.' . $body, $secret ); if ( ! hash_equals( $expected, $sig ) ) { return new WP_Error( 'tedrix_bad_sig', 'Invalid signature.', [ 'status' => 401 ] ); } return true; } function tedrix_rest_ping( WP_REST_Request $req ) { return new WP_REST_Response( [ 'ok' => true, 'plugin' => 'tedrix', 'version' => '1.1.0', 'site_id' => get_option( TEDRIX_OPT_SITEID, '' ), 'connected' => (bool) get_option( TEDRIX_OPT_SECRET, '' ), ], 200 ); } /** Find the target post from an explicit id, or by matching a page URL/slug. */ function tedrix_find_post( array $p ) { if ( ! empty( $p['post_id'] ) ) { $post = get_post( (int) $p['post_id'] ); if ( $post ) { return $post; } } if ( ! empty( $p['url'] ) ) { $id = url_to_postid( esc_url_raw( $p['url'] ) ); if ( $id ) { return get_post( $id ); } // Fall back to matching by slug (last path segment). $slug = trim( wp_parse_url( $p['url'], PHP_URL_PATH ) ?? '', '/' ); $slug = $slug ? basename( $slug ) : ''; if ( $slug ) { $q = get_posts( [ 'name' => sanitize_title( $slug ), 'post_type' => [ 'post', 'page' ], 'numberposts' => 1, 'post_status' => 'publish' ] ); if ( $q ) { return $q[0]; } } } return null; } /** Which SEO plugins are active? Their fields are what actually reaches Google. * NOTE: All in One SEO v4 stores title/description in its own tables, not in * postmeta, so writing the old _aioseo_* keys would do nothing. Until a real * AIOSEO integration exists we honestly don't claim it. */ function tedrix_active_seo_plugins() { $active = []; if ( defined( 'WPSEO_VERSION' ) ) { $active[] = 'yoast'; } if ( class_exists( 'RankMath' ) ) { $active[] = 'rankmath'; } return $active; } /** The SEO meta keys Tedrix touches, per plugin. */ function tedrix_seo_meta_keys() { return [ 'yoast' => [ 'title' => '_yoast_wpseo_title', 'desc' => '_yoast_wpseo_metadesc' ], 'rankmath' => [ 'title' => 'rank_math_title', 'desc' => 'rank_math_description' ], ]; } /** Write the meta description to whichever SEO plugin is active (best-effort). * With no SEO plugin detected we write both keys so the value is there if one * gets installed. */ function tedrix_set_meta_description( int $post_id, string $desc ) { $active = tedrix_active_seo_plugins(); $keys = tedrix_seo_meta_keys(); $targets = $active ? $active : array_keys( $keys ); foreach ( $targets as $plugin ) { update_post_meta( $post_id, $keys[ $plugin ]['desc'], wp_slash( $desc ) ); } } /** * Write the SEO title override for ACTIVE SEO plugins only. This matters: if a * Yoast/Rank Math title override exists, changing post_title alone changes * nothing in Google — the override wins. Only touched when an override is * already set or an SEO plugin is active, and only with the approved title. */ function tedrix_set_seo_title( int $post_id, string $title ) { $keys = tedrix_seo_meta_keys(); foreach ( tedrix_active_seo_plugins() as $plugin ) { $key = $keys[ $plugin ]['title']; // Set the override only if one already exists — otherwise the plugin's // title template (with the site name) keeps doing its job and the new // post_title flows through it. $existing = get_post_meta( $post_id, $key, true ); if ( $existing !== '' ) { update_post_meta( $post_id, $key, wp_slash( $title ) ); } } } /* * Skriver ut sidans JSON-LD i . Bara på enskilda inlägg och sidor, och * bara när det finns något sparat: en tom script-tagg är skräp för både * Google och läsare. */ add_action( 'wp_head', function () { if ( ! is_singular() ) { return; } $id = get_queried_object_id(); $json = $id ? get_post_meta( $id, '_tedrix_jsonld', true ) : ''; if ( ! $json ) { return; } // Redan validerad och normaliserad vid apply, men aldrig lita blint: // ogiltig JSON här skulle underkänna hela sidans strukturerade data. $data = json_decode( $json, true ); if ( ! is_array( $data ) ) { return; } /* Re-encoded with JSON_HEX_TAG so a stored value can never close the * script tag, and printed through core's own helper (WP 5.7+). */ wp_print_inline_script_tag( wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP ), array( 'type' => 'application/ld+json' ) ); }, 20 ); /** * Apply an already-approved change. Only whitelisted fields are touched. A * revision is saved first (native WP), and we stash the pre-change snapshot so a * revert can restore it. */ function tedrix_rest_apply( WP_REST_Request $req ) { $ok = tedrix_verify( $req ); if ( is_wp_error( $ok ) ) { return $ok; } $p = json_decode( $req->get_body(), true ); if ( ! is_array( $p ) ) { return new WP_Error( 'tedrix_bad_json', 'Bad payload.', [ 'status' => 400 ] ); } $post = tedrix_find_post( $p ); if ( ! $post ) { return new WP_Error( 'tedrix_no_post', 'Could not find the target post.', [ 'status' => 404 ] ); } $fields = []; $update = [ 'ID' => $post->ID ]; $before = [ 'post_title' => $post->post_title, 'post_content' => $post->post_content ]; // Snapshot the SEO plugin fields too, so a revert restores exactly what was. $before['seo_meta'] = []; foreach ( tedrix_seo_meta_keys() as $plugin => $keys ) { foreach ( $keys as $k ) { $before['seo_meta'][ $k ] = get_post_meta( $post->ID, $k, true ); } } if ( isset( $p['title'] ) && is_string( $p['title'] ) && $p['title'] !== '' ) { $update['post_title'] = wp_strip_all_tags( $p['title'] ); tedrix_set_seo_title( $post->ID, wp_strip_all_tags( $p['title'] ) ); $fields[] = 'title'; } if ( isset( $p['content'] ) && is_string( $p['content'] ) && $p['content'] !== '' ) { $update['post_content'] = wp_kses_post( $p['content'] ); $fields[] = 'content'; } /* * STRUKTURERAD DATA (JSON-LD). * * Går medvetet INTE via post_content: wp_kses_post() ovan strippar *