'GET', 'callback' => 'a1tools_rest_get_variables', 'permission_callback' => '__return_true', ) ); // Clear cache (requires admin). register_rest_route( 'a1-tools/v1', '/site-variables/clear-cache', array( 'methods' => 'POST', 'callback' => 'a1tools_rest_clear_cache', 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, ) ); // Yoast meta endpoints. register_rest_route( 'a1-tools/v1', '/yoast-meta/(?P\d+)', array( 'methods' => 'POST', 'callback' => 'a1tools_update_yoast_meta', 'permission_callback' => function () { return current_user_can( 'edit_posts' ); }, 'args' => array( 'id' => array( 'description' => 'Post ID', 'type' => 'integer', 'required' => true, 'validate_callback' => function ( $param ) { return is_numeric( $param ) && $param > 0; }, ), ), ) ); register_rest_route( 'a1-tools/v1', '/yoast-meta/(?P\d+)', array( 'methods' => 'GET', 'callback' => 'a1tools_get_yoast_meta', 'permission_callback' => function () { return current_user_can( 'edit_posts' ); }, 'args' => array( 'id' => array( 'description' => 'Post ID', 'type' => 'integer', 'required' => true, 'validate_callback' => function ( $param ) { return is_numeric( $param ) && $param > 0; }, ), ), ) ); // Blog sync - list posts with full metadata. register_rest_route( 'a1-tools/v1', '/blog-posts', array( 'methods' => 'GET', 'callback' => 'a1tools_get_blog_posts', 'permission_callback' => 'a1tools_rest_check_auth', 'args' => array( 'per_page' => array( 'default' => 50, 'sanitize_callback' => 'absint', ), 'page' => array( 'default' => 1, 'sanitize_callback' => 'absint', ), 'status' => array( 'default' => 'publish,draft', 'sanitize_callback' => 'sanitize_text_field', ), 'search' => array( 'default' => '', 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); // Blog sync - get full post for syncing. register_rest_route( 'a1-tools/v1', '/blog-posts/(?P\d+)', array( 'methods' => 'GET', 'callback' => 'a1tools_get_blog_post_detail', 'permission_callback' => 'a1tools_rest_check_auth', ) ); // Blog sync - create post from sync data. register_rest_route( 'a1-tools/v1', '/blog-sync/create', array( 'methods' => 'POST', 'callback' => 'a1tools_create_synced_post', 'permission_callback' => 'a1tools_rest_check_auth', ) ); // Blog sync - update synced post. register_rest_route( 'a1-tools/v1', '/blog-sync/update/(?P\d+)', array( 'methods' => 'POST', 'callback' => 'a1tools_update_synced_post', 'permission_callback' => 'a1tools_rest_check_auth', ) ); // 2.5.0 — Web-form submission proxy. Public (no WP auth) — the // widget POSTs from a visitor's browser, this endpoint signs the // payload with the form HMAC secret server-side, and forwards to // `tools.a-1chimney.com/api/web_forms.php`. The visitor never sees // the secret. register_rest_route( 'a1-tools/v1', '/submit-web-form', array( 'methods' => 'POST', 'callback' => 'a1tools_rest_submit_web_form', 'permission_callback' => '__return_true', ) ); // Cache invalidation webhook (authenticated via shared secret). register_rest_route( 'a1-tools/v1', '/invalidate-cache', array( 'methods' => 'POST', 'callback' => 'a1tools_rest_invalidate_cache', 'permission_callback' => function ( WP_REST_Request $request ) { // Validate via shared secret in header. $secret = $request->get_header( 'X-Cache-Secret' ); if ( empty( $secret ) ) { $secret = ''; } $expected = get_option( 'a1tools_form_secret', '' ); if ( empty( $expected ) || empty( $secret ) ) { return false; } return hash_equals( $expected, $secret ); }, ) ); } add_action( 'rest_api_init', 'a1tools_register_rest_routes' ); /** * REST: Get site variables. * * @since 1.0.0 * @return WP_REST_Response|WP_Error Response object. */ function a1tools_rest_get_variables() { $variables = a1tools_get_site_variables(); if ( null === $variables ) { return new WP_Error( 'fetch_error', __( 'Could not fetch site variables', 'a1-tools' ), array( 'status' => 500 ) ); } return new WP_REST_Response( array( 'success' => true, 'variables' => $variables, 'cached' => false !== get_transient( A1TOOLS_CACHE_KEY ), ), 200 ); } /** * REST: Clear variables cache. * * @since 1.0.0 * @return WP_REST_Response Response object. */ function a1tools_rest_clear_cache() { a1tools_clear_cache(); return new WP_REST_Response( array( 'success' => true, 'message' => __( 'Cache cleared', 'a1-tools' ), ), 200 ); } /** * REST: Invalidate cache via webhook. * * Called externally by the A1 Tools platform when site variables or * business data changes, so WordPress sites can bust their caches * without waiting for the transient to expire. * * @since 2.1.0 * @param WP_REST_Request $request The request object. * @return WP_REST_Response Response object. */ function a1tools_rest_invalidate_cache( $request ) { a1tools_clear_cache(); return new WP_REST_Response( array( 'success' => true, 'message' => 'Cache cleared', 'cleared_at' => current_time( 'mysql' ), ), 200 ); } // ============================================================================ // BLOG SYNC ENDPOINTS // ============================================================================ /** * REST: Permission callback for blog sync endpoints. * * Validates via shared secret in the X-A1-Secret header. * * @since 2.2.0 * @param WP_REST_Request $request The request object. * @return bool True if authenticated, false otherwise. */ function a1tools_rest_check_auth( WP_REST_Request $request ) { $secret = $request->get_header( 'X-A1-Secret' ); if ( empty( $secret ) ) { $secret = ''; } // Hardened 2026-05-04 (P6 #27): blog-sync routes now check // `a1tools_blog_sync_secret` first, falling back to the legacy // `a1tools_form_secret` for backwards compat. Operators should // generate a fresh per-purpose secret in wp-admin → Settings → // A1 Tools, then remove the shared secret from the cache-invalidation // header. One leaked secret no longer = full read+write to every // post on every connected site. $expected_blog = get_option( 'a1tools_blog_sync_secret', '' ); $expected_legacy = get_option( 'a1tools_form_secret', '' ); if ( empty( $secret ) ) { return false; } $ok = false; if ( ! empty( $expected_blog ) ) { $ok = hash_equals( $expected_blog, $secret ); } if ( ! $ok && ! empty( $expected_legacy ) ) { // Fall back to the legacy shared secret. Log so operators can see // when the shared-secret window can be closed. if ( hash_equals( $expected_legacy, $secret ) ) { error_log( '[a1tools] blog-sync request used legacy a1tools_form_secret. Provision a1tools_blog_sync_secret to retire the shared secret.' ); $ok = true; } } return $ok; } /** * REST: Get blog posts list with metadata. * * @since 2.2.0 * @param WP_REST_Request $request The request object. * @return WP_REST_Response Response object. */ function a1tools_get_blog_posts( $request ) { $per_page = $request->get_param( 'per_page' ); $page = $request->get_param( 'page' ); $status = $request->get_param( 'status' ); $search = $request->get_param( 'search' ); $args = array( 'post_type' => 'post', 'post_status' => explode( ',', $status ), 'posts_per_page' => min( $per_page, 100 ), 'paged' => $page, 'orderby' => 'date', 'order' => 'DESC', ); if ( ! empty( $search ) ) { $args['s'] = $search; } $query = new WP_Query( $args ); $posts = array(); // Prime the post-meta and term caches once for the whole batch so the // per-post get_post_meta() / wp_get_post_categories() / wp_get_post_tags() // calls below resolve from cache instead of firing a query each time. // This converts the old N+1 pattern (4+ queries per post) into a constant // number of queries per page. $post_ids = wp_list_pluck( $query->posts, 'ID' ); if ( ! empty( $post_ids ) ) { update_post_caches( $query->posts, 'post', true, true ); update_object_term_cache( $post_ids, 'post' ); } $yoast_available = function_exists( 'YoastSEO' ); foreach ( $query->posts as $post ) { $thumbnail_id = get_post_thumbnail_id( $post->ID ); $thumbnail_url = $thumbnail_id ? wp_get_attachment_url( $thumbnail_id ) : null; $categories = wp_get_post_categories( $post->ID, array( 'fields' => 'names' ) ); $tags = wp_get_post_tags( $post->ID, array( 'fields' => 'names' ) ); // Get Yoast SEO data if available. Meta cache was primed above so these // are in-memory lookups, not queries. $yoast = array(); if ( $yoast_available ) { $yoast = array( 'focuskw' => get_post_meta( $post->ID, '_yoast_wpseo_focuskw', true ), 'metadesc' => get_post_meta( $post->ID, '_yoast_wpseo_metadesc', true ), 'title' => get_post_meta( $post->ID, '_yoast_wpseo_title', true ), ); } $posts[] = array( 'id' => $post->ID, 'title' => $post->post_title, 'slug' => $post->post_name, 'status' => $post->post_status, 'date' => $post->post_date, 'modified' => $post->post_modified, 'excerpt' => wp_trim_words( $post->post_excerpt ? $post->post_excerpt : $post->post_content, 30 ), 'featured_image_url' => $thumbnail_url, 'categories' => $categories, 'tags' => $tags, 'author' => get_the_author_meta( 'display_name', $post->post_author ), 'yoast' => $yoast, 'content_hash' => md5( $post->post_title . $post->post_content . $post->post_excerpt ), ); } return new WP_REST_Response( array( 'posts' => $posts, 'total' => (int) $query->found_posts, 'total_pages' => (int) $query->max_num_pages, 'page' => $page, ), 200 ); } /** * REST: Get single blog post with full content and SEO data. * * @since 2.2.0 * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object. */ function a1tools_get_blog_post_detail( $request ) { $post_id = (int) $request['id']; $post = get_post( $post_id ); if ( ! $post ) { return new WP_Error( 'not_found', 'Post not found', array( 'status' => 404 ) ); } $thumbnail_id = get_post_thumbnail_id( $post->ID ); $thumbnail_url = $thumbnail_id ? wp_get_attachment_url( $thumbnail_id ) : null; $categories = wp_get_post_categories( $post->ID, array( 'fields' => 'names' ) ); $tags = wp_get_post_tags( $post->ID, array( 'fields' => 'names' ) ); // Full Yoast SEO data. $yoast = array(); $yoast_keys = array( 'focuskw', 'metadesc', 'title', 'canonical', 'is_cornerstone', 'keywordsynonyms', 'focuskeywords', 'opengraph-title', 'opengraph-description', 'opengraph-image', 'twitter-title', 'twitter-description', 'twitter-image', ); foreach ( $yoast_keys as $key ) { $value = get_post_meta( $post->ID, '_yoast_wpseo_' . $key, true ); if ( '' !== $value && false !== $value ) { $yoast[ $key ] = $value; } } return new WP_REST_Response( array( 'id' => $post->ID, 'title' => $post->post_title, 'content' => $post->post_content, 'excerpt' => $post->post_excerpt, 'slug' => $post->post_name, 'status' => $post->post_status, 'date' => $post->post_date, 'modified' => $post->post_modified, 'featured_image_url' => $thumbnail_url, 'categories' => $categories, 'tags' => $tags, 'author_id' => (int) $post->post_author, 'author_name' => get_the_author_meta( 'display_name', $post->post_author ), 'yoast' => $yoast, 'content_hash' => md5( $post->post_title . $post->post_content . $post->post_excerpt ), ), 200 ); } /** * REST: Create a new post from sync data. * * @since 2.2.0 * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object. */ function a1tools_create_synced_post( $request ) { $body = $request->get_json_params(); if ( empty( $body['title'] ) ) { return new WP_Error( 'missing_title', 'Post title is required', array( 'status' => 400 ) ); } $allowed_statuses = array( 'publish', 'draft', 'pending', 'private' ); $post_status = sanitize_text_field( $body['status'] ?? 'draft' ); if ( ! in_array( $post_status, $allowed_statuses, true ) ) { $post_status = 'draft'; } $post_data = array( 'post_title' => sanitize_text_field( $body['title'] ), 'post_content' => wp_kses_post( $body['content'] ?? '' ), 'post_excerpt' => sanitize_textarea_field( $body['excerpt'] ?? '' ), 'post_status' => $post_status, 'post_name' => sanitize_title( $body['slug'] ?? '' ), ); // Set author. if ( ! empty( $body['author_name'] ) ) { $user = get_user_by( 'login', $body['author_name'] ); if ( ! $user ) { $user = get_user_by( 'slug', sanitize_title( $body['author_name'] ) ); } if ( $user ) { $post_data['post_author'] = $user->ID; } } $post_id = wp_insert_post( $post_data, true ); if ( is_wp_error( $post_id ) ) { return $post_id; } // Handle categories. if ( ! empty( $body['categories'] ) && is_array( $body['categories'] ) ) { $cat_ids = array(); foreach ( $body['categories'] as $cat_name ) { $term = get_term_by( 'name', $cat_name, 'category' ); if ( ! $term ) { $result = wp_insert_term( sanitize_text_field( $cat_name ), 'category' ); if ( ! is_wp_error( $result ) ) { $cat_ids[] = $result['term_id']; } } else { $cat_ids[] = $term->term_id; } } if ( ! empty( $cat_ids ) ) { wp_set_post_categories( $post_id, $cat_ids ); } } // Handle tags. if ( ! empty( $body['tags'] ) && is_array( $body['tags'] ) ) { wp_set_post_tags( $post_id, array_map( 'sanitize_text_field', $body['tags'] ) ); } // Handle featured image from URL. if ( ! empty( $body['featured_image_url'] ) ) { $image_id = a1tools_sideload_image( $body['featured_image_url'], $post_id ); if ( $image_id && ! is_wp_error( $image_id ) ) { set_post_thumbnail( $post_id, $image_id ); } } // Handle Yoast SEO meta. if ( ! empty( $body['yoast'] ) && is_array( $body['yoast'] ) ) { foreach ( $body['yoast'] as $key => $value ) { update_post_meta( $post_id, '_yoast_wpseo_' . sanitize_key( $key ), sanitize_text_field( $value ) ); } } return new WP_REST_Response( array( 'id' => $post_id, 'title' => get_the_title( $post_id ), 'slug' => get_post_field( 'post_name', $post_id ), 'status' => get_post_status( $post_id ), 'link' => get_permalink( $post_id ), 'message' => 'Post created successfully', ), 201 ); } /** * REST: Update an existing post from sync data. * * @since 2.2.0 * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object. */ function a1tools_update_synced_post( $request ) { $post_id = (int) $request['id']; $post = get_post( $post_id ); if ( ! $post ) { return new WP_Error( 'not_found', 'Post not found', array( 'status' => 404 ) ); } $body = $request->get_json_params(); $post_data = array( 'ID' => $post_id, ); if ( isset( $body['title'] ) ) { $post_data['post_title'] = sanitize_text_field( $body['title'] ); } if ( isset( $body['content'] ) ) { $post_data['post_content'] = wp_kses_post( $body['content'] ); } if ( isset( $body['excerpt'] ) ) { $post_data['post_excerpt'] = sanitize_textarea_field( $body['excerpt'] ); } if ( isset( $body['status'] ) ) { $allowed_statuses = array( 'publish', 'draft', 'pending', 'private' ); $post_status = sanitize_text_field( $body['status'] ); $post_data['post_status'] = in_array( $post_status, $allowed_statuses, true ) ? $post_status : 'draft'; } if ( isset( $body['slug'] ) ) { $post_data['post_name'] = sanitize_title( $body['slug'] ); } // Set author. if ( ! empty( $body['author_name'] ) ) { $user = get_user_by( 'login', $body['author_name'] ); if ( ! $user ) { $user = get_user_by( 'slug', sanitize_title( $body['author_name'] ) ); } if ( $user ) { $post_data['post_author'] = $user->ID; } } $result = wp_update_post( $post_data, true ); if ( is_wp_error( $result ) ) { return $result; } // Handle categories. if ( ! empty( $body['categories'] ) && is_array( $body['categories'] ) ) { $cat_ids = array(); foreach ( $body['categories'] as $cat_name ) { $term = get_term_by( 'name', $cat_name, 'category' ); if ( ! $term ) { $term_result = wp_insert_term( sanitize_text_field( $cat_name ), 'category' ); if ( ! is_wp_error( $term_result ) ) { $cat_ids[] = $term_result['term_id']; } } else { $cat_ids[] = $term->term_id; } } if ( ! empty( $cat_ids ) ) { wp_set_post_categories( $post_id, $cat_ids ); } } // Handle tags. if ( ! empty( $body['tags'] ) && is_array( $body['tags'] ) ) { wp_set_post_tags( $post_id, array_map( 'sanitize_text_field', $body['tags'] ) ); } // Handle featured image from URL. if ( ! empty( $body['featured_image_url'] ) ) { $image_id = a1tools_sideload_image( $body['featured_image_url'], $post_id ); if ( $image_id && ! is_wp_error( $image_id ) ) { set_post_thumbnail( $post_id, $image_id ); } } // Handle Yoast SEO meta. if ( ! empty( $body['yoast'] ) && is_array( $body['yoast'] ) ) { foreach ( $body['yoast'] as $key => $value ) { update_post_meta( $post_id, '_yoast_wpseo_' . sanitize_key( $key ), sanitize_text_field( $value ) ); } } return new WP_REST_Response( array( 'id' => $post_id, 'title' => get_the_title( $post_id ), 'slug' => get_post_field( 'post_name', $post_id ), 'status' => get_post_status( $post_id ), 'link' => get_permalink( $post_id ), 'message' => 'Post updated successfully', ), 200 ); } /** * Sideload an image from a URL and attach it to a post. * * @since 2.2.0 * @param string $url The image URL. * @param int $post_id The post ID to attach the image to. * @return int|WP_Error Attachment ID on success, WP_Error on failure. */ function a1tools_sideload_image( $url, $post_id ) { require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; $tmp = download_url( $url ); if ( is_wp_error( $tmp ) ) { return $tmp; } $file_array = array( 'name' => basename( wp_parse_url( $url, PHP_URL_PATH ) ), 'tmp_name' => $tmp, ); $id = media_handle_sideload( $file_array, $post_id ); if ( is_wp_error( $id ) ) { wp_delete_file( $tmp ); } return $id; } /** * REST: Public proxy that signs and forwards a web-form submission to * `tools.a-1chimney.com/api/web_forms.php`. (2.5.0) * * The visitor's browser POSTs JSON to * `/wp-json/a1-tools/v1/submit-web-form`. We: * 1. Look up the form HMAC secret from `a1tools_form_secret`. * 2. Recompute the body, prepend a timestamp, sign with * HMAC-SHA256, and POST to the upstream A1 Tools API with * X-Form-Secret / X-Form-Timestamp / X-Form-Signature headers * — same scheme `a1-tools-forms.php` uses for Elementor Pro * form submissions. * 3. Pass the upstream response through to the visitor verbatim. * * If the secret isn't configured on this WordPress install, the * widget will surface an error message — the admin needs to visit * "A1 Tools → Form Integration" and paste the secret first. */ function a1tools_rest_submit_web_form( WP_REST_Request $request ) { $secret = get_option( 'a1tools_form_secret', '' ); $apiBase = trim( get_option( 'a1tools_api_url', '' ) ); if ( empty( $secret ) ) { return new WP_REST_Response( array( 'success' => false, 'error' => 'Plugin is missing the form HMAC secret. Set it in A1 Tools → Form Integration.', ), 500 ); } $apiUrl = 'https://tools.a-1chimney.com/api/web_forms.php'; if ( ! empty( $apiBase ) ) { // Allow an override host (e.g. staging) by replacing the // `website_variables.php` base with `web_forms.php`. $parsed = wp_parse_url( $apiBase ); if ( $parsed && ! empty( $parsed['scheme'] ) && ! empty( $parsed['host'] ) ) { $apiUrl = $parsed['scheme'] . '://' . $parsed['host'] . '/api/web_forms.php'; } } $payload = $request->get_json_params(); if ( ! is_array( $payload ) ) { return new WP_REST_Response( array( 'success' => false, 'error' => 'Invalid JSON payload' ), 400 ); } $body = wp_json_encode( $payload ); $timestamp = (string) time(); $signature = hash_hmac( 'sha256', $body . $timestamp, $secret ); $response = wp_remote_post( $apiUrl, array( 'headers' => array( 'Content-Type' => 'application/json', 'X-Form-Secret' => $secret, 'X-Form-Timestamp' => $timestamp, 'X-Form-Signature' => $signature, ), 'body' => $body, 'timeout' => 15, ) ); if ( is_wp_error( $response ) ) { return new WP_REST_Response( array( 'success' => false, 'error' => 'Upstream network error: ' . $response->get_error_message(), ), 502 ); } $status = wp_remote_retrieve_response_code( $response ); $raw = wp_remote_retrieve_body( $response ); $decoded = json_decode( $raw, true ); if ( ! is_array( $decoded ) ) { $decoded = array( 'success' => false, 'error' => 'Upstream returned non-JSON', ); } return new WP_REST_Response( $decoded, $status ?: 502 ); }