' . 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; }
?>
'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
*