→
' . 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', [] ); ?>

' . 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'; } if ( defined( 'SEOPRESS_VERSION' ) ) { $active[] = 'seopress'; } if ( function_exists( 'the_seo_framework' ) ) { $active[] = 'tsf'; } if ( defined( 'AIOSEO_VERSION' ) || function_exists( 'aioseo' ) ) { $active[] = 'aioseo'; } return $active; } /** * The SEO meta keys Tedrix reads and writes, per plugin. All in One SEO keeps * its values in its own table, so it has no entry here (it is read separately * in tedrix_aioseo_rad()). The 'tedrix' entry is the description Tedrix * prints itself when no SEO plugin is active. */ function tedrix_seo_meta_keys() { return [ 'yoast' => [ 'title' => '_yoast_wpseo_title', 'desc' => '_yoast_wpseo_metadesc' ], 'rankmath' => [ 'title' => 'rank_math_title', 'desc' => 'rank_math_description' ], 'seopress' => [ 'title' => '_seopress_titles_title', 'desc' => '_seopress_titles_desc' ], 'tsf' => [ 'title' => '_genesis_title', 'desc' => '_genesis_description' ], 'tedrix' => [ 'desc' => '_tedrix_metadesc' ], ]; } /** Write the meta description to whichever SEO plugin is active. With no SEO * plugin, WordPress prints no description at all, so Tedrix stores its own * and prints it in wp_head (see below); the Yoast and Rank Math keys are * written too so the value is there if one of them gets installed. */ function tedrix_set_meta_description( int $post_id, string $desc ) { $keys = tedrix_seo_meta_keys(); $targets = array_values( array_filter( tedrix_active_seo_plugins(), function ( $p ) use ( $keys ) { return isset( $keys[ $p ]['desc'] ); } ) ); if ( ! tedrix_active_seo_plugins() ) { $targets = [ 'yoast', 'rankmath', 'tedrix' ]; } foreach ( $targets as $plugin ) { update_post_meta( $post_id, $keys[ $plugin ]['desc'], wp_slash( $desc ) ); } tedrix_aioseo_skriv( $post_id, 'description', $desc ); } /** * All in One SEO keeps title and description in its own table, not in post * meta. Returns the table name when AIOSEO is active and the table exists. * * @return string */ function tedrix_aioseo_tabell() { if ( ! in_array( 'aioseo', tedrix_active_seo_plugins(), true ) ) { return ''; } global $wpdb; $namn = $wpdb->prefix . 'aioseo_posts'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- AIOSEO has no API for this; checked once per request. return $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $namn ) ) ) === $namn ? $namn : ''; } /** * Write one AIOSEO field (title or description) for a post, creating the row * if AIOSEO has not made one yet. null clears the field. * * @param int $post_id Post ID. * @param string $falt 'title' or 'description'. * @param string|null $varde New value. */ function tedrix_aioseo_skriv( $post_id, $falt, $varde ) { $tabell = tedrix_aioseo_tabell(); if ( '' === $tabell || ! in_array( $falt, array( 'title', 'description' ), true ) ) { return; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name is the site prefix plus a fixed string. $finns = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$tabell} WHERE post_id = %d", $post_id ) ); $nu = current_time( 'mysql' ); if ( $finns ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( $tabell, array( $falt => $varde, 'updated' => $nu ), array( 'post_id' => $post_id ) ); } elseif ( null !== $varde ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->insert( $tabell, array( 'post_id' => $post_id, $falt => $varde, 'created' => $nu, 'updated' => $nu ) ); } if ( function_exists( 'aioseo' ) && isset( aioseo()->core->cache ) && method_exists( aioseo()->core->cache, 'clearPrefix' ) ) { aioseo()->core->cache->clearPrefix( 'aioseo_posts_' ); } } /** * The current AIOSEO title and description for a post, for the undo snapshot. * * @param int $post_id Post ID. * @return array|null */ function tedrix_aioseo_las( $post_id ) { $tabell = tedrix_aioseo_tabell(); if ( '' === $tabell ) { return null; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name is the site prefix plus a fixed string. $rad = $wpdb->get_row( $wpdb->prepare( "SELECT title, description FROM {$tabell} WHERE post_id = %d", $post_id ), ARRAY_A ); return is_array( $rad ) ? $rad : array( 'title' => null, 'description' => null ); } /** * 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 ) { if ( empty( $keys[ $plugin ]['title'] ) ) { continue; } $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 ) ); } } $aio = tedrix_aioseo_las( $post_id ); if ( is_array( $aio ) && ! empty( $aio['title'] ) ) { tedrix_aioseo_skriv( $post_id, 'title', $title ); } } /* * With no SEO plugin active, WordPress prints no meta description. If Tedrix * has applied one, print it here; with an SEO plugin active, that plugin * prints the value Tedrix wrote into its own field instead. */ add_action( 'wp_head', function () { if ( ! is_singular() || tedrix_active_seo_plugins() ) { return; } $id = get_queried_object_id(); $desc = $id ? (string) get_post_meta( $id, '_tedrix_metadesc', true ) : ''; if ( '' !== $desc ) { echo '' . "\n"; } }, 1 ); /* * 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 ); } } $aio = tedrix_aioseo_las( $post->ID ); if ( null !== $aio ) { $before['aioseo'] = $aio; } 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 *