/* Plugin Name: Practice Radar Connector Plugin URI: https://practiceradar.co.uk/connector Description: Lets Practice Radar read your pages and apply the changes you approve there: titles, descriptions, social previews, alt text, structured data, FAQ blocks, internal links, redirects, compressed images and new local pages. Nothing changes without your approval in Practice Radar, and every change can be put back with one click. Version: 0.4.0 Author: Practice Radar Author URI: https://practiceradar.co.uk License: GPLv2 or later */ /* This file works two ways: uploaded as a plugin (with the element, so a value holding "" would end it and whatever followed would run as script on the page. JSON allows \u003c anywhere a "<" can appear, and "<" can only appear inside a string, so this is lossless and closes the gap for values stored before 0.3.1 as well as new ones. */ function pr_connector_ld( $s ) { return str_replace( array( '<', '>' ), array( '\u003c', '\u003e' ), (string) $s ); } function pr_connector_token() { $t = get_option( 'pr_connector_token' ); if ( ! $t ) { $t = wp_generate_password( 40, false, false ); add_option( 'pr_connector_token', $t, '', 'no' ); } return $t; } function pr_connector_seo() { if ( defined( 'WPSEO_VERSION' ) ) { return 'yoast'; } if ( class_exists( 'RankMath' ) ) { return 'rankmath'; } if ( defined( 'SEOPRESS_VERSION' ) ) { return 'seopress'; } return 'none'; } /* Field -> post meta key, per SEO plugin. */ function pr_connector_keys() { switch ( pr_connector_seo() ) { case 'yoast': return array( 'seo_title' => '_yoast_wpseo_title', 'seo_desc' => '_yoast_wpseo_metadesc', 'og_title' => '_yoast_wpseo_opengraph-title', 'og_desc' => '_yoast_wpseo_opengraph-description' ); case 'rankmath': return array( 'seo_title' => 'rank_math_title', 'seo_desc' => 'rank_math_description', 'og_title' => 'rank_math_facebook_title', 'og_desc' => 'rank_math_facebook_description' ); case 'seopress': return array( 'seo_title' => '_seopress_titles_title', 'seo_desc' => '_seopress_titles_desc', 'og_title' => '_seopress_social_fb_title', 'og_desc' => '_seopress_social_fb_desc' ); } return null; } function pr_connector_auth( $request ) { $sent = $request->get_header( 'x-practice-radar-token' ); if ( ! $sent ) { return new WP_Error( 'pr_no_token', 'Missing token', array( 'status' => 401 ) ); } if ( ! hash_equals( pr_connector_token(), (string) $sent ) ) { return new WP_Error( 'pr_bad_token', 'Wrong token', array( 'status' => 403 ) ); } return true; } function pr_connector_effective( $id ) { $keys = pr_connector_keys(); $out = array( 'rendered_title' => '', 'rendered_desc' => '' ); foreach ( array( 'seo_title', 'seo_desc', 'og_title', 'og_desc' ) as $f ) { $out[ $f ] = $keys ? (string) get_post_meta( $id, $keys[ $f ], true ) : ''; } if ( pr_connector_seo() === 'yoast' && function_exists( 'YoastSEO' ) ) { try { $m = YoastSEO()->meta->for_post( $id ); if ( $m ) { $out['rendered_title'] = (string) $m->title; $out['rendered_desc'] = (string) $m->description; } } catch ( Throwable $e ) { /* older Yoast, fall through */ } } return $out; } function pr_connector_row( $p ) { return array_merge( array( 'id' => (int) $p->ID, 'type' => $p->post_type, 'url' => get_permalink( $p ), 'post_title' => get_the_title( $p ), 'modified' => $p->post_modified_gmt, ), pr_connector_effective( $p->ID ) ); } function pr_connector_types() { return array_values( array_diff( get_post_types( array( 'public' => true ), 'names' ), array( 'attachment' ) ) ); } function pr_connector_log_add( $entry ) { $log = get_option( 'pr_connector_log', array() ); if ( ! is_array( $log ) ) { $log = array(); } array_unshift( $log, $entry ); $log = array_slice( $log, 0, 300 ); update_option( 'pr_connector_log', $log, 'no' ); } function pr_connector_log_id() { return substr( md5( uniqid( '', true ) ), 0, 12 ); } function pr_connector_purge( $id ) { if ( $id ) { clean_post_cache( $id ); do_action( 'litespeed_purge_post', $id ); } else { do_action( 'litespeed_purge_all' ); } } /* The images a page's content carries, with their alt text as written and * the attachment they resolve to, so alt text can be set in both places. */ function pr_connector_images_in( $p ) { $out = array(); if ( ! preg_match_all( '/]*>/i', (string) $p->post_content, $tags ) ) { return $out; } foreach ( $tags[0] as $tag ) { if ( ! preg_match( '/\bsrc=["\']([^"\']+)["\']/i', $tag, $sm ) ) { continue; } $src = $sm[1]; $alt = preg_match( '/\balt=["\']([^"\']*)["\']/i', $tag, $am ) ? $am[1] : null; $base = preg_replace( '/-\d+x\d+(\.[a-z0-9]+)$/i', '$1', $src ); $aid = attachment_url_to_postid( $base ); if ( ! $aid ) { $aid = attachment_url_to_postid( $src ); } $out[] = array( 'page_id' => (int) $p->ID, 'page_url' => get_permalink( $p ), 'page_title' => get_the_title( $p ), 'src' => $src, 'alt' => $alt === null ? '' : $alt, 'has_alt' => ( $alt !== null && trim( $alt ) !== '' ), 'attachment_id' => (int) $aid, 'attachment_alt' => $aid ? (string) get_post_meta( $aid, '_wp_attachment_image_alt', true ) : '', 'file' => basename( parse_url( $src, PHP_URL_PATH ) ), ); } return $out; } function pr_connector_clean_html( $html ) { $allowed = array( 'p' => array( 'class' => true ), 'h2' => array( 'id' => true, 'class' => true ), 'h3' => array( 'id' => true, 'class' => true ), 'h1' => array( 'class' => true ), 'ul' => array( 'class' => true ), 'ol' => array( 'class' => true ), 'li' => array(), 'strong' => array(), 'em' => array(), 'br' => array(), 'a' => array( 'href' => true, 'title' => true, 'rel' => true ), 'section' => array( 'class' => true, 'id' => true ), 'div' => array( 'class' => true, 'id' => true ), 'img' => array( 'src' => true, 'alt' => true, 'width' => true, 'height' => true, 'class' => true, 'loading' => true, 'decoding' => true, 'srcset' => true, 'sizes' => true ), 'details' => array( 'class' => true ), 'summary' => array(), 'blockquote' => array(), 'table' => array(), 'thead' => array(), 'tbody' => array(), 'tr' => array(), 'th' => array(), 'td' => array(), ); return trim( wp_kses( (string) $html, $allowed ) ); } function pr_connector_headings( $content ) { $out = array(); if ( preg_match_all( '/]*>(.*?)<\/h\1>/is', (string) $content, $m, PREG_SET_ORDER ) ) { foreach ( $m as $x ) { $out[] = array( 'level' => (int) $x[1], 'text' => trim( wp_strip_all_tags( $x[2] ) ), 'html' => $x[0] ); } } return $out; } function pr_connector_links_in( $content ) { $out = array(); if ( preg_match_all( '/]*href=["\']([^"\']+)["\'][^>]*>(.*?)<\/a>/is', (string) $content, $m, PREG_SET_ORDER ) ) { foreach ( $m as $x ) { $out[] = array( 'href' => $x[1], 'text' => trim( wp_strip_all_tags( $x[2] ) ), 'html' => $x[0] ); } } return $out; } function pr_connector_author() { $admins = get_users( array( 'role' => 'administrator', 'number' => 1, 'orderby' => 'ID', 'order' => 'ASC' ) ); return $admins ? (int) $admins[0]->ID : 1; } function pr_connector_path( $url ) { $path = (string) parse_url( $url, PHP_URL_PATH ); if ( $path === '' ) { $path = '/'; } return '/' . trim( $path, '/' ) . ( $path === '/' ? '' : '/' ); } function pr_connector_redirects() { $l = get_option( 'pr_connector_redirects', array() ); return is_array( $l ) ? $l : array(); } add_action( 'template_redirect', function () { if ( is_admin() ) { return; } $list = pr_connector_redirects(); if ( ! $list ) { return; } $path = pr_connector_path( (string) ( $_SERVER['REQUEST_URI'] ?? '/' ) ); /* a site in a subfolder: the stored paths are relative to the site, not the host */ $home = pr_connector_path( home_url( '/' ) ); if ( $home !== '/' && strpos( $path, $home ) === 0 ) { $path = '/' . ltrim( substr( $path, strlen( $home ) ), '/' ); if ( $path !== '/' ) { $path = rtrim( $path, '/' ) . '/'; } } if ( isset( $list[ $path ] ) && $list[ $path ] !== '' ) { $to = $list[ $path ]; wp_safe_redirect( strpos( $to, '/' ) === 0 ? home_url( $to ) : $to, 301, 'Practice Radar' ); exit; } }, 1 ); /* Per-page structured data, printed on that page only. */ add_action( 'wp_head', function () { if ( ! is_singular() ) { return; } $s = get_post_meta( get_queried_object_id(), '_pr_schema', true ); if ( ! $s ) { return; } echo "\n\n"; }, 6 ); /** * IndexNow (0.4.0). When Practice Radar applies a change you approved, it * tells the search engines that take part in IndexNow (Bing, Yandex and * others) that the page changed, so they re-read it within minutes instead of * at the next crawl. IndexNow asks the site to prove it agreed, with a key * file at the site's root: this makes the key once and serves that one file, * as plain text. Nothing else is sent anywhere by the plugin itself. */ function pr_connector_indexnow_key() { $key = (string) get_option( 'pr_connector_indexnow_key', '' ); if ( ! preg_match( '/^[a-z0-9]{32}$/', $key ) ) { $key = strtolower( wp_generate_password( 32, false, false ) ); update_option( 'pr_connector_indexnow_key', $key, true ); } return $key; } add_action( 'init', function () { $key = (string) get_option( 'pr_connector_indexnow_key', '' ); if ( ! preg_match( '/^[a-z0-9]{32}$/', $key ) ) { return; } $path = (string) wp_parse_url( isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '', PHP_URL_PATH ); $home = (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ); if ( $path === rtrim( $home, '/' ) . '/' . $key . '.txt' ) { nocache_headers(); header( 'Content-Type: text/plain; charset=utf-8' ); echo $key; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- 32 lowercase letters and digits, checked above exit; } }, 0 ); add_action( 'rest_api_init', function () { $ns = 'practice-radar/v1'; register_rest_route( $ns, '/ping', array( 'methods' => 'GET', 'permission_callback' => 'pr_connector_auth', 'callback' => function () { return array( 'ok' => true, 'site' => home_url( '/' ), 'name' => get_bloginfo( 'name' ), 'version' => PR_CONNECTOR_VERSION, 'seo_plugin' => pr_connector_seo(), 'wp' => get_bloginfo( 'version' ), 'pages' => (int) wp_count_posts( 'page' )->publish, 'posts' => (int) wp_count_posts( 'post' )->publish, 'schema' => (bool) get_option( 'pr_connector_schema' ), 'redirects' => count( pr_connector_redirects() ), 'can' => array( 'append', 'replace', 'create', 'redirect', 'image_replace', 'page_schema', 'indexnow' ), 'indexnow_key' => pr_connector_indexnow_key(), ); }, ) ); register_rest_route( $ns, '/pages', array( 'methods' => 'GET', 'permission_callback' => 'pr_connector_auth', 'callback' => function () { $out = array(); $posts = get_posts( array( 'post_type' => pr_connector_types(), 'post_status' => 'publish', 'numberposts' => 800, 'orderby' => 'ID', 'order' => 'ASC' ) ); foreach ( $posts as $p ) { $out[] = pr_connector_row( $p ); } return $out; }, ) ); register_rest_route( $ns, '/page/(?P\d+)', array( array( 'methods' => 'GET', 'permission_callback' => 'pr_connector_auth', 'callback' => function ( $req ) { $p = get_post( (int) $req['id'] ); if ( ! $p || $p->post_status !== 'publish' ) { return new WP_Error( 'pr_no_page', 'No such page', array( 'status' => 404 ) ); } return pr_connector_row( $p ); }, ), array( 'methods' => 'POST', 'permission_callback' => 'pr_connector_auth', 'callback' => function ( $req ) { $p = get_post( (int) $req['id'] ); if ( ! $p || $p->post_status !== 'publish' ) { return new WP_Error( 'pr_no_page', 'No such page', array( 'status' => 404 ) ); } $keys = pr_connector_keys(); if ( ! $keys ) { return new WP_Error( 'pr_no_seo', 'No supported SEO plugin (Yoast, Rank Math or SEOPress) is active', array( 'status' => 409 ) ); } $body = $req->get_json_params(); if ( ! is_array( $body ) ) { $body = array(); } $changes = array(); foreach ( array( 'seo_title' => 120, 'seo_desc' => 320, 'og_title' => 120, 'og_desc' => 320 ) as $field => $max ) { if ( ! array_key_exists( $field, $body ) ) { continue; } $after = trim( wp_strip_all_tags( (string) $body[ $field ] ) ); if ( strlen( $after ) > $max ) { return new WP_Error( 'pr_too_long', $field . ' is over ' . $max . ' characters', array( 'status' => 422 ) ); } $before = (string) get_post_meta( $p->ID, $keys[ $field ], true ); if ( $before === $after ) { continue; } update_post_meta( $p->ID, $keys[ $field ], $after ); $changes[] = array( 'kind' => 'meta', 'post' => (int) $p->ID, 'field' => $field, 'key' => $keys[ $field ], 'before' => $before, 'after' => $after ); } $log_id = pr_connector_log_id(); if ( $changes ) { pr_connector_log_add( array( 'id' => $log_id, 'ts' => gmdate( 'c' ), 'post' => (int) $p->ID, 'changes' => $changes, 'via' => 'practice-radar' ) ); pr_connector_purge( $p->ID ); } return array( 'ok' => true, 'log_id' => $changes ? $log_id : '', 'changed' => count( $changes ), 'page' => pr_connector_row( $p ) ); }, ), ) ); /* Images without alt text, across every published page. */ register_rest_route( $ns, '/images', array( 'methods' => 'GET', 'permission_callback' => 'pr_connector_auth', 'callback' => function ( $req ) { $out = array(); $only_missing = $req->get_param( 'all' ) ? false : true; $posts = get_posts( array( 'post_type' => pr_connector_types(), 'post_status' => 'publish', 'numberposts' => 400, 'orderby' => 'ID', 'order' => 'ASC' ) ); foreach ( $posts as $p ) { foreach ( pr_connector_images_in( $p ) as $im ) { if ( $only_missing && ( $im['has_alt'] || $im['attachment_alt'] !== '' ) ) { continue; } $out[] = $im; if ( count( $out ) >= 300 ) { break 2; } } } return $out; }, ) ); /* Set an image's alt text: on the attachment, and on every empty alt for * that file inside the given page's content, so the change shows on the * page as well as in the library. */ register_rest_route( $ns, '/image', array( 'methods' => 'POST', 'permission_callback' => 'pr_connector_auth', 'callback' => function ( $req ) { $body = $req->get_json_params(); if ( ! is_array( $body ) ) { $body = array(); } $alt = trim( wp_strip_all_tags( (string) ( $body['alt'] ?? '' ) ) ); if ( $alt === '' || strlen( $alt ) > 200 ) { return new WP_Error( 'pr_bad_alt', 'Alt text must be 1 to 200 characters', array( 'status' => 422 ) ); } $aid = (int) ( $body['attachment_id'] ?? 0 ); $pid = (int) ( $body['page_id'] ?? 0 ); $src = (string) ( $body['src'] ?? '' ); $changes = array(); if ( $aid && get_post( $aid ) ) { $before = (string) get_post_meta( $aid, '_wp_attachment_image_alt', true ); if ( $before !== $alt ) { update_post_meta( $aid, '_wp_attachment_image_alt', $alt ); $changes[] = array( 'kind' => 'meta', 'post' => $aid, 'field' => 'alt', 'key' => '_wp_attachment_image_alt', 'before' => $before, 'after' => $alt ); } } $p = $pid ? get_post( $pid ) : null; if ( $p && $p->post_status !== 'publish' ) { $p = null; } if ( $p && $src ) { $content = (string) $p->post_content; $file = preg_quote( basename( parse_url( $src, PHP_URL_PATH ) ), '/' ); $esc = esc_attr( $alt ); $new = preg_replace_callback( '/]*' . $file . '[^>]*>/i', function ( $m ) use ( $esc ) { $tag = $m[0]; if ( preg_match( '/\balt=["\']\s*["\']/i', $tag ) ) { return preg_replace( '/\balt=["\']\s*["\']/i', 'alt="' . $esc . '"', $tag, 1 ); } if ( ! preg_match( '/\balt=/i', $tag ) ) { return preg_replace( '/^ $pid, 'post_content' => $new ) ); $changes[] = array( 'kind' => 'content', 'post' => $pid, 'field' => 'alt', 'before' => $content, 'after' => $new ); } } $log_id = pr_connector_log_id(); if ( $changes ) { pr_connector_log_add( array( 'id' => $log_id, 'ts' => gmdate( 'c' ), 'post' => $pid ?: $aid, 'changes' => $changes, 'via' => 'practice-radar' ) ); pr_connector_purge( $pid ?: 0 ); } return array( 'ok' => true, 'log_id' => $changes ? $log_id : '', 'changed' => count( $changes ) ); }, ) ); /* One structured-data block for the front page, kept as an option and * printed in the head. Sent as a JSON object; an empty body removes it. */ register_rest_route( $ns, '/schema', array( array( 'methods' => 'GET', 'permission_callback' => 'pr_connector_auth', 'callback' => function () { $s = get_option( 'pr_connector_schema' ); return array( 'schema' => $s ? json_decode( $s, true ) : null ); }, ), array( 'methods' => 'POST', 'permission_callback' => 'pr_connector_auth', 'callback' => function ( $req ) { $body = $req->get_json_params(); $before = (string) get_option( 'pr_connector_schema', '' ); $schema = is_array( $body ) ? ( $body['schema'] ?? null ) : null; if ( $schema && is_array( $schema ) ) { $after = wp_json_encode( $schema, JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE ); if ( strlen( $after ) > 20000 ) { return new WP_Error( 'pr_too_long', 'Schema is over 20,000 characters', array( 'status' => 422 ) ); } update_option( 'pr_connector_schema', $after, 'no' ); } else { $after = ''; delete_option( 'pr_connector_schema' ); } $log_id = pr_connector_log_id(); if ( $before !== $after ) { pr_connector_log_add( array( 'id' => $log_id, 'ts' => gmdate( 'c' ), 'post' => 0, 'changes' => array( array( 'kind' => 'schema', 'post' => 0, 'field' => 'schema', 'before' => $before, 'after' => $after ) ), 'via' => 'practice-radar' ) ); pr_connector_purge( 0 ); } return array( 'ok' => true, 'log_id' => $before !== $after ? $log_id : '', 'changed' => $before !== $after ? 1 : 0 ); }, ), ) ); /* One page in full: content, headings and excerpt, for the proposals * that need to read the page rather than its metadata. */ register_rest_route( $ns, '/page/(?P\d+)/full', array( 'methods' => 'GET', 'permission_callback' => 'pr_connector_auth', 'callback' => function ( $req ) { $p = get_post( (int) $req['id'] ); if ( ! $p || $p->post_status !== 'publish' ) { return new WP_Error( 'pr_no_page', 'No such page', array( 'status' => 404 ) ); } $row = pr_connector_row( $p ); $row['content'] = (string) $p->post_content; $row['excerpt'] = (string) $p->post_excerpt; $row['headings'] = pr_connector_headings( $p->post_content ); $row['links'] = pr_connector_links_in( $p->post_content ); $row['schema'] = (string) get_post_meta( $p->ID, '_pr_schema', true ); $row['thumbnail'] = get_the_post_thumbnail_url( $p, 'full' ) ?: ''; return $row; }, ) ); /* Append a block of HTML to the end of a page's content (an FAQ, a * sentence with an internal link). Reversible: the whole content before * is logged. */ register_rest_route( $ns, '/page/(?P\d+)/append', array( 'methods' => 'POST', 'permission_callback' => 'pr_connector_auth', 'callback' => function ( $req ) { $p = get_post( (int) $req['id'] ); if ( ! $p || $p->post_status !== 'publish' ) { return new WP_Error( 'pr_no_page', 'No such page', array( 'status' => 404 ) ); } $body = $req->get_json_params(); if ( ! is_array( $body ) ) { $body = array(); } $html = pr_connector_clean_html( (string) ( $body['html'] ?? '' ) ); if ( $html === '' || strlen( $html ) > 20000 ) { return new WP_Error( 'pr_bad_html', 'The block must be 1 to 20,000 characters of plain HTML', array( 'status' => 422 ) ); } $before = (string) $p->post_content; $after = ( ( $body['where'] ?? 'end' ) === 'start' ) ? $html . "\n\n" . ltrim( $before ) : rtrim( $before ) . "\n\n" . $html . "\n"; $r = wp_update_post( array( 'ID' => $p->ID, 'post_content' => $after ), true ); if ( is_wp_error( $r ) ) { return $r; } $log_id = pr_connector_log_id(); pr_connector_log_add( array( 'id' => $log_id, 'ts' => gmdate( 'c' ), 'post' => (int) $p->ID, 'changes' => array( array( 'kind' => 'content', 'post' => (int) $p->ID, 'field' => (string) ( $body['field'] ?? 'append' ), 'before' => $before, 'after' => $after ) ), 'via' => 'practice-radar' ) ); pr_connector_purge( $p->ID ); return array( 'ok' => true, 'log_id' => $log_id, 'changed' => 1 ); }, ) ); /* Replace one exact fragment inside a page's content with another: a * heading, a link's target, an image tag with width and height added. * The fragment must occur exactly once, so nothing is changed by * accident. Reversible the same way. */ register_rest_route( $ns, '/page/(?P\d+)/replace', array( 'methods' => 'POST', 'permission_callback' => 'pr_connector_auth', 'callback' => function ( $req ) { $p = get_post( (int) $req['id'] ); if ( ! $p || $p->post_status !== 'publish' ) { return new WP_Error( 'pr_no_page', 'No such page', array( 'status' => 404 ) ); } $body = $req->get_json_params(); if ( ! is_array( $body ) ) { $body = array(); } $find = (string) ( $body['find'] ?? '' ); $repl = pr_connector_clean_html( (string) ( $body['replace'] ?? '' ) ); if ( $find === '' || strlen( $find ) > 20000 || strlen( $repl ) > 20000 ) { return new WP_Error( 'pr_bad_find', 'find and replace must each be 1 to 20,000 characters', array( 'status' => 422 ) ); } $before = (string) $p->post_content; $n = substr_count( $before, $find ); if ( $n !== 1 ) { return new WP_Error( 'pr_not_unique', $n ? 'That fragment appears ' . $n . ' times on the page, so it was left alone' : 'That fragment is not on the page any more', array( 'status' => 409 ) ); } $after = str_replace( $find, $repl, $before ); $r = wp_update_post( array( 'ID' => $p->ID, 'post_content' => $after ), true ); if ( is_wp_error( $r ) ) { return $r; } $log_id = pr_connector_log_id(); pr_connector_log_add( array( 'id' => $log_id, 'ts' => gmdate( 'c' ), 'post' => (int) $p->ID, 'changes' => array( array( 'kind' => 'content', 'post' => (int) $p->ID, 'field' => (string) ( $body['field'] ?? 'replace' ), 'before' => $before, 'after' => $after ) ), 'via' => 'practice-radar' ) ); pr_connector_purge( $p->ID ); return array( 'ok' => true, 'log_id' => $log_id, 'changed' => 1 ); }, ) ); /* A new page, created as a DRAFT unless status says publish, with its * title, slug, content, and SEO title and description through the SEO * plugin. Reversible: the revert trashes it. */ register_rest_route( $ns, '/page/create', array( 'methods' => 'POST', 'permission_callback' => 'pr_connector_auth', 'callback' => function ( $req ) { $body = $req->get_json_params(); if ( ! is_array( $body ) ) { $body = array(); } $title = trim( wp_strip_all_tags( (string) ( $body['title'] ?? '' ) ) ); $html = pr_connector_clean_html( (string) ( $body['content'] ?? '' ) ); if ( $title === '' || strlen( $title ) > 200 ) { return new WP_Error( 'pr_bad_title', 'A page needs a title of 1 to 200 characters', array( 'status' => 422 ) ); } if ( $html === '' || strlen( $html ) > 60000 ) { return new WP_Error( 'pr_bad_html', 'A page needs 1 to 60,000 characters of content', array( 'status' => 422 ) ); } $status = ( ( $body['status'] ?? 'draft' ) === 'publish' ) ? 'publish' : 'draft'; $type = in_array( (string) ( $body['type'] ?? 'page' ), pr_connector_types(), true ) ? (string) $body['type'] : 'page'; $args = array( 'post_title' => $title, 'post_content' => $html, 'post_status' => $status, 'post_type' => $type, 'post_author' => pr_connector_author() ); if ( ! empty( $body['slug'] ) ) { $args['post_name'] = sanitize_title( (string) $body['slug'] ); } if ( ! empty( $body['parent'] ) && get_post( (int) $body['parent'] ) ) { $args['post_parent'] = (int) $body['parent']; } $id = wp_insert_post( $args, true ); if ( is_wp_error( $id ) ) { return $id; } $keys = pr_connector_keys(); if ( $keys ) { foreach ( array( 'seo_title' => 120, 'seo_desc' => 320 ) as $f => $max ) { $v = trim( wp_strip_all_tags( (string) ( $body[ $f ] ?? '' ) ) ); if ( $v !== '' && strlen( $v ) <= $max ) { update_post_meta( $id, $keys[ $f ], $v ); } } } $log_id = pr_connector_log_id(); pr_connector_log_add( array( 'id' => $log_id, 'ts' => gmdate( 'c' ), 'post' => (int) $id, 'changes' => array( array( 'kind' => 'page', 'post' => (int) $id, 'field' => 'page', 'before' => '', 'after' => $status ) ), 'via' => 'practice-radar' ) ); pr_connector_purge( 0 ); $p = get_post( $id ); return array( 'ok' => true, 'log_id' => $log_id, 'changed' => 1, 'id' => (int) $id, 'status' => $status, 'url' => $status === 'publish' ? get_permalink( $p ) : get_preview_post_link( $p ), 'edit' => get_edit_post_link( $id, 'raw' ) ); }, ) ); /* Redirects the connector holds: a dead path sent to a live one with a * 301, kept as an option and served on template_redirect. */ register_rest_route( $ns, '/redirects', array( array( 'methods' => 'GET', 'permission_callback' => 'pr_connector_auth', 'callback' => function () { return array( 'redirects' => pr_connector_redirects() ); }, ), array( 'methods' => 'POST', 'permission_callback' => 'pr_connector_auth', 'callback' => function ( $req ) { $body = $req->get_json_params(); if ( ! is_array( $body ) ) { $body = array(); } $from = pr_connector_path( (string) ( $body['from'] ?? '' ) ); $home = pr_connector_path( home_url( '/' ) ); if ( $home !== '/' && strpos( $from, $home ) === 0 ) { $from = '/' . ltrim( substr( $from, strlen( $home ) ), '/' ); if ( $from !== '/' ) { $from = rtrim( $from, '/' ) . '/'; } } $to = (string) ( $body['to'] ?? '' ); if ( $from === '' || $from === '/' ) { return new WP_Error( 'pr_bad_from', 'from must be a path on this site, not the home page', array( 'status' => 422 ) ); } /* A prefix test let https://site.co.uk.evil.com and https://site.co.uk@evil.com through. The host must match exactly, and a path must not be protocol-relative. */ $ok_to = ( strpos( $to, '/' ) === 0 && strpos( $to, '//' ) !== 0 ) || ( strtolower( (string) wp_parse_url( $to, PHP_URL_HOST ) ) === strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) && in_array( strtolower( (string) wp_parse_url( $to, PHP_URL_SCHEME ) ), array( 'http', 'https' ), true ) ); if ( $to === '' || strlen( $to ) > 500 || ! $ok_to ) { return new WP_Error( 'pr_bad_to', 'to must be a path or address on this site', array( 'status' => 422 ) ); } $list = pr_connector_redirects(); $before = isset( $list[ $from ] ) ? (string) $list[ $from ] : ''; $list[ $from ] = $to; update_option( 'pr_connector_redirects', $list, 'no' ); $log_id = pr_connector_log_id(); pr_connector_log_add( array( 'id' => $log_id, 'ts' => gmdate( 'c' ), 'post' => 0, 'changes' => array( array( 'kind' => 'redirect', 'post' => 0, 'field' => $from, 'before' => $before, 'after' => $to ) ), 'via' => 'practice-radar' ) ); pr_connector_purge( 0 ); return array( 'ok' => true, 'log_id' => $log_id, 'changed' => 1 ); }, ), ) ); /* A replacement image: the new file (base64) becomes a new attachment, * and every tag on the page that used the old file is pointed at it, * with width and height set. The old attachment is untouched, so the * revert (content before) restores the page exactly. */ register_rest_route( $ns, '/image/replace', array( 'methods' => 'POST', 'permission_callback' => 'pr_connector_auth', 'callback' => function ( $req ) { $body = $req->get_json_params(); if ( ! is_array( $body ) ) { $body = array(); } $pid = (int) ( $body['page_id'] ?? 0 ); $p = $pid ? get_post( $pid ) : null; if ( ! $p || $p->post_status !== 'publish' ) { return new WP_Error( 'pr_no_page', 'No such page', array( 'status' => 404 ) ); } $src = (string) ( $body['src'] ?? '' ); $data = (string) ( $body['data'] ?? '' ); $name = sanitize_file_name( (string) ( $body['filename'] ?? 'image.webp' ) ); $w = (int) ( $body['width'] ?? 0 ); $h = (int) ( $body['height'] ?? 0 ); if ( $src === '' || $data === '' ) { return new WP_Error( 'pr_bad_image', 'src and data are required', array( 'status' => 422 ) ); } $bytes = base64_decode( $data, true ); if ( $bytes === false || strlen( $bytes ) > 6000000 ) { return new WP_Error( 'pr_bad_image', 'The image must be base64 and under 6 MB', array( 'status' => 422 ) ); } /* Judged by its content, not its name: only a real JPEG, PNG, WebP or GIF is accepted, and the file is named for what it actually is. */ $info = @getimagesizefromstring( $bytes ); $exts = array( 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif' ); if ( ! $info || ! isset( $exts[ $info['mime'] ] ) ) { return new WP_Error( 'pr_bad_image', 'Only a JPEG, PNG, WebP or GIF image can be uploaded', array( 'status' => 422 ) ); } $name = sanitize_file_name( pathinfo( $name, PATHINFO_FILENAME ) . '.' . $exts[ $info['mime'] ] ); $up = wp_upload_bits( $name, null, $bytes ); if ( ! empty( $up['error'] ) ) { return new WP_Error( 'pr_upload', (string) $up['error'], array( 'status' => 500 ) ); } $ft = wp_check_filetype( $up['file'] ); $aid = wp_insert_attachment( array( 'post_mime_type' => $ft['type'] ?: 'image/webp', 'post_title' => pathinfo( $name, PATHINFO_FILENAME ), 'post_status' => 'inherit', 'post_parent' => $pid ), $up['file'], $pid ); if ( is_wp_error( $aid ) ) { return $aid; } require_once ABSPATH . 'wp-admin/includes/image.php'; wp_update_attachment_metadata( $aid, wp_generate_attachment_metadata( $aid, $up['file'] ) ); $alt = trim( wp_strip_all_tags( (string) ( $body['alt'] ?? '' ) ) ); if ( $alt !== '' ) { update_post_meta( $aid, '_wp_attachment_image_alt', $alt ); } $new_url = $up['url']; $before = (string) $p->post_content; $file = preg_quote( basename( parse_url( $src, PHP_URL_PATH ) ), '/' ); $after = preg_replace_callback( '/]*' . $file . '[^>]*>/i', function ( $m ) use ( $src, $new_url, $w, $h, $aid ) { $tag = $m[0]; $tag = preg_replace( '/\bsrc=["\'][^"\']*["\']/i', 'src="' . esc_url( $new_url ) . '"', $tag, 1 ); $tag = preg_replace( '/\bsrcset=["\'][^"\']*["\']/i', '', $tag, 1 ); $tag = preg_replace( '/\bsizes=["\'][^"\']*["\']/i', '', $tag, 1 ); if ( $w && $h ) { $tag = preg_replace( '/\bwidth=["\']\d*["\']/i', '', $tag ); $tag = preg_replace( '/\bheight=["\']\d*["\']/i', '', $tag ); $tag = preg_replace( '/^ 409 ) ); } $r = wp_update_post( array( 'ID' => $pid, 'post_content' => $after ), true ); if ( is_wp_error( $r ) ) { return $r; } $log_id = pr_connector_log_id(); pr_connector_log_add( array( 'id' => $log_id, 'ts' => gmdate( 'c' ), 'post' => $pid, 'changes' => array( array( 'kind' => 'content', 'post' => $pid, 'field' => 'image', 'before' => $before, 'after' => $after ), array( 'kind' => 'attachment', 'post' => (int) $aid, 'field' => 'image', 'before' => '', 'after' => $new_url ) ), 'via' => 'practice-radar' ) ); pr_connector_purge( $pid ); return array( 'ok' => true, 'log_id' => $log_id, 'changed' => 1, 'attachment_id' => (int) $aid, 'url' => $new_url ); }, ) ); /* Structured data for ONE page (a treatment, a person), kept in post * meta and printed in the head of that page only. */ register_rest_route( $ns, '/page/(?P\d+)/schema', array( 'methods' => 'POST', 'permission_callback' => 'pr_connector_auth', 'callback' => function ( $req ) { $p = get_post( (int) $req['id'] ); if ( ! $p || $p->post_status !== 'publish' ) { return new WP_Error( 'pr_no_page', 'No such page', array( 'status' => 404 ) ); } $body = $req->get_json_params(); if ( ! is_array( $body ) ) { $body = array(); } $before = (string) get_post_meta( $p->ID, '_pr_schema', true ); $schema = $body['schema'] ?? null; if ( $schema && is_array( $schema ) ) { $after = wp_json_encode( $schema, JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE ); if ( strlen( $after ) > 20000 ) { return new WP_Error( 'pr_too_long', 'Schema is over 20,000 characters', array( 'status' => 422 ) ); } update_post_meta( $p->ID, '_pr_schema', $after ); } else { $after = ''; delete_post_meta( $p->ID, '_pr_schema' ); } $log_id = pr_connector_log_id(); if ( $before !== $after ) { pr_connector_log_add( array( 'id' => $log_id, 'ts' => gmdate( 'c' ), 'post' => (int) $p->ID, 'changes' => array( array( 'kind' => 'meta', 'post' => (int) $p->ID, 'field' => 'schema_page', 'key' => '_pr_schema', 'before' => $before, 'after' => $after ) ), 'via' => 'practice-radar' ) ); pr_connector_purge( $p->ID ); } return array( 'ok' => true, 'log_id' => $before !== $after ? $log_id : '', 'changed' => $before !== $after ? 1 : 0 ); }, ) ); register_rest_route( $ns, '/revert/(?P[a-f0-9]+)', array( 'methods' => 'POST', 'permission_callback' => 'pr_connector_auth', 'callback' => function ( $req ) { $log = get_option( 'pr_connector_log', array() ); foreach ( (array) $log as $e ) { if ( ! isset( $e['id'] ) || $e['id'] !== $req['log_id'] || empty( $e['changes'] ) ) { continue; } $touched = array(); foreach ( $e['changes'] as $ch ) { $kind = $ch['kind'] ?? 'meta'; if ( $kind === 'meta' ) { $key = $ch['key'] ?? null; if ( ! $key ) { $keys = pr_connector_keys(); $key = $keys ? ( $keys[ $ch['field'] ] ?? null ) : null; } if ( ! $key ) { continue; } if ( $ch['before'] === '' ) { delete_post_meta( (int) $ch['post'], $key ); } else { update_post_meta( (int) $ch['post'], $key, $ch['before'] ); } $touched[] = (int) $ch['post']; } elseif ( $kind === 'content' ) { wp_update_post( array( 'ID' => (int) $ch['post'], 'post_content' => $ch['before'] ) ); $touched[] = (int) $ch['post']; } elseif ( $kind === 'schema' ) { if ( $ch['before'] === '' ) { delete_option( 'pr_connector_schema' ); } else { update_option( 'pr_connector_schema', $ch['before'], 'no' ); } $touched[] = 0; } elseif ( $kind === 'page' ) { /* a page the connector created: trashed, never deleted */ if ( get_post( (int) $ch['post'] ) ) { wp_trash_post( (int) $ch['post'] ); } $touched[] = 0; } elseif ( $kind === 'redirect' ) { $list = pr_connector_redirects(); if ( $ch['before'] === '' ) { unset( $list[ $ch['field'] ] ); } else { $list[ $ch['field'] ] = $ch['before']; } update_option( 'pr_connector_redirects', $list, 'no' ); $touched[] = 0; } elseif ( $kind === 'attachment' ) { /* the compressed file the page no longer points at, once the content is back */ if ( get_post( (int) $ch['post'] ) ) { wp_delete_attachment( (int) $ch['post'], true ); } } } pr_connector_log_add( array( 'id' => pr_connector_log_id(), 'ts' => gmdate( 'c' ), 'post' => (int) $e['post'], 'reverted' => $e['id'], 'via' => 'practice-radar' ) ); foreach ( array_unique( $touched ) as $t ) { pr_connector_purge( $t ); } return array( 'ok' => true ); } return new WP_Error( 'pr_no_log', 'No such change', array( 'status' => 404 ) ); }, ) ); register_rest_route( $ns, '/log', array( 'methods' => 'GET', 'permission_callback' => 'pr_connector_auth', 'callback' => function () { $out = array(); foreach ( array_slice( (array) get_option( 'pr_connector_log', array() ), 0, 100 ) as $e ) { /* content before/after can be a whole page; the log lists what changed, not the page */ if ( ! empty( $e['changes'] ) ) { foreach ( $e['changes'] as &$ch ) { if ( ( $ch['kind'] ?? '' ) === 'content' ) { $ch['before'] = '[page content]'; $ch['after'] = '[page content]'; } } } $out[] = $e; } return $out; }, ) ); /* The token, for the site's own administrators only (cookie or application * password), so a practice can read it without opening wp-admin. */ register_rest_route( $ns, '/token', array( 'methods' => 'GET', 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, 'callback' => function () { return array( 'site' => home_url( '/' ), 'token' => pr_connector_token(), 'seo_plugin' => pr_connector_seo() ); }, ) ); } ); /* Print the stored structured data on the front page. */ add_action( 'wp_head', function () { if ( ! is_front_page() ) { return; } $s = get_option( 'pr_connector_schema' ); if ( ! $s ) { return; } echo "\n\n"; }, 5 ); add_action( 'admin_menu', function () { add_options_page( 'Practice Radar', 'Practice Radar', 'manage_options', 'practice-radar', function () { echo '

Practice Radar

'; echo '

Paste this address and token into Practice Radar under Website changes, in the Execution group of the sidebar.

'; echo '

Site ' . esc_html( home_url( '/' ) ) . '

'; if ( isset( $_POST['pr_regen'] ) && check_admin_referer( 'pr_regen_token' ) && current_user_can( 'manage_options' ) ) { update_option( 'pr_connector_token', wp_generate_password( 40, false, false ), 'no' ); echo '

A new token has been made. Paste it into Practice Radar; the old one no longer works.

'; } echo '

Token ' . esc_html( pr_connector_token() ) . '

'; echo '
'; wp_nonce_field( 'pr_regen_token' ); echo '

Use this if the token may have been seen by someone else. Practice Radar stops working with this site until the new token is pasted in.

'; echo '

Practice Radar can read your pages and apply the changes you approve there: titles, descriptions, social previews, alt text, structured data, FAQ blocks, internal links, redirects, compressed images and new local pages. Nothing changes without your approval, and every change can be put back with one click. SEO plugin detected: ' . esc_html( pr_connector_seo() ) . '.

'; echo '
'; } ); } );