From c35b3c36418d24c90a97296634e4fc29ac2bcd8e Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Fri, 21 Nov 2025 15:18:48 +0000 Subject: [PATCH] Add Site Metadata Endpoint and API Key Management - Introduced a new Site Metadata endpoint (`GET /wp-json/igny8/v1/site-metadata/`) for retrieving available post types and taxonomies, including counts. - Added API key input in the admin settings for authentication, with secure storage and revocation functionality. - Implemented a toggle for enabling/disabling two-way sync operations. - Updated documentation to reflect new features and usage examples. - Enhanced permission checks for REST API calls to ensure secure access. --- backend/celerybeat-schedule | Bin 16384 -> 16384 bytes igny8-wp-integration-plugin/README.md | 29 ++++ .../admin/class-admin.php | 97 ++++++++--- .../admin/settings.php | 52 ++++++ .../docs/WORDPRESS-PLUGIN-INTEGRATION.md | 49 ++++++ .../includes/class-igny8-api.php | 15 ++ .../includes/class-igny8-rest-api.php | 154 ++++++++++++++++-- .../tests/test-revoke-api-key.php | 28 ++++ .../tests/test-site-metadata.php | 36 ++++ master-docs/WORDPRESS-PLUGIN-INTEGRATION.md | 49 ++++++ 10 files changed, 475 insertions(+), 34 deletions(-) create mode 100644 igny8-wp-integration-plugin/tests/test-revoke-api-key.php create mode 100644 igny8-wp-integration-plugin/tests/test-site-metadata.php diff --git a/backend/celerybeat-schedule b/backend/celerybeat-schedule index 120172756759a9a7dfe7f18cf27a31d4f815cda8..f634f04dd614327c81cb94b72f1769b0f6bcf900 100644 GIT binary patch delta 35 rcmZo@U~Fh$++b=Zz|Y0Nz)&_NL$qy5&=lVi=E(*o%9}IHW^e)kxMd2S delta 35 rcmZo@U~Fh$++b=Zz-!LHz~D0_L$qy5&=lWP=E(*o%9}IHW^e)kxPA(d diff --git a/igny8-wp-integration-plugin/README.md b/igny8-wp-integration-plugin/README.md index 4b91d333..fa7604b8 100644 --- a/igny8-wp-integration-plugin/README.md +++ b/igny8-wp-integration-plugin/README.md @@ -249,6 +249,35 @@ $result = $integration->full_site_scan(); - `igny8_send_site_data_to_igny8($site_id)` - Send site data to IGNY8 - `igny8_map_site_to_semantic_strategy($site_id, $site_data)` - Map to semantic structure +### Site Metadata Endpoint (Plugin) + +The plugin exposes a discovery endpoint that your IGNY8 app can call to learn which WordPress post types and taxonomies exist and how many items each contains. + +- Endpoint: `GET /wp-json/igny8/v1/site-metadata/` +- Auth: Plugin-level connection must be enabled and authenticated (plugin accepts stored API key or access token) +- Response: IGNY8 unified response format (`success`, `data`, `message`, `request_id`) + +Example response: + +```json +{ + "success": true, + "data": { + "post_types": { + "post": { "label": "Posts", "count": 123 }, + "page": { "label": "Pages", "count": 12 } + }, + "taxonomies": { + "category": { "label": "Categories", "count": 25 }, + "post_tag": { "label": "Tags", "count": 102 } + }, + "generated_at": 1700553600 + }, + "message": "Site metadata retrieved", + "request_id": "550e8400-e29b-41d4-a716-446655440000" +} +``` + --- ## File Structure diff --git a/igny8-wp-integration-plugin/admin/class-admin.php b/igny8-wp-integration-plugin/admin/class-admin.php index 423ff9f7..f6b41efd 100644 --- a/igny8-wp-integration-plugin/admin/class-admin.php +++ b/igny8-wp-integration-plugin/admin/class-admin.php @@ -64,6 +64,11 @@ class Igny8Admin { public function register_settings() { register_setting('igny8_settings', 'igny8_email'); register_setting('igny8_settings', 'igny8_site_id'); + register_setting('igny8_settings', 'igny8_enable_two_way_sync', array( + 'type' => 'boolean', + 'sanitize_callback' => array($this, 'sanitize_boolean'), + 'default' => 1 + )); register_setting('igny8_bridge_connection', 'igny8_connection_enabled', array( 'type' => 'boolean', @@ -161,6 +166,17 @@ class Igny8Admin { $this->handle_connection(); } + // Handle revoke API key + if (isset($_POST['igny8_revoke_api_key']) && check_admin_referer('igny8_revoke_api_key')) { + self::revoke_api_key(); + add_settings_error( + 'igny8_settings', + 'igny8_api_key_revoked', + __('API key revoked and removed from this site.', 'igny8-bridge'), + 'updated' + ); + } + // Handle webhook secret regeneration if (isset($_POST['igny8_regenerate_secret']) && check_admin_referer('igny8_regenerate_secret')) { $new_secret = igny8_regenerate_webhook_secret(); @@ -182,43 +198,78 @@ class Igny8Admin { private function handle_connection() { $email = sanitize_email($_POST['igny8_email'] ?? ''); $password = $_POST['igny8_password'] ?? ''; - - if (empty($email) || empty($password)) { + $api_key = sanitize_text_field($_POST['igny8_api_key'] ?? ''); + + // Require email, password AND API key per updated policy + if (empty($email) || empty($password) || empty($api_key)) { add_settings_error( 'igny8_settings', 'igny8_error', - __('Email and password are required.', 'igny8-bridge'), + __('Email, password and API key are all required to establish the connection.', 'igny8-bridge'), 'error' ); return; } - + + // First, attempt login with email/password $api = new Igny8API(); - - if ($api->login($email, $password)) { - update_option('igny8_email', $email); - - // Try to get site ID (if available) - $site_response = $api->get('/system/sites/'); - if ($site_response['success'] && !empty($site_response['results'])) { - $site = $site_response['results'][0]; - update_option('igny8_site_id', $site['id']); - } - - add_settings_error( - 'igny8_settings', - 'igny8_connected', - __('Successfully connected to IGNY8 API.', 'igny8-bridge'), - 'updated' - ); - } else { + + if (!$api->login($email, $password)) { add_settings_error( 'igny8_settings', 'igny8_error', - __('Failed to connect to IGNY8 API. Please check your credentials.', 'igny8-bridge'), + __('Failed to connect to IGNY8 API with provided credentials.', 'igny8-bridge'), 'error' ); + return; } + + // Store email + update_option('igny8_email', $email); + + // Store API key securely and also set access token to the API key for subsequent calls if desired + if (function_exists('igny8_store_secure_option')) { + igny8_store_secure_option('igny8_api_key', $api_key); + igny8_store_secure_option('igny8_access_token', $api_key); + } else { + update_option('igny8_api_key', $api_key); + update_option('igny8_access_token', $api_key); + } + + // Try to get site ID (if available) using the authenticated client + $site_response = $api->get('/system/sites/'); + if ($site_response['success'] && !empty($site_response['results'])) { + $site = $site_response['results'][0]; + update_option('igny8_site_id', $site['id']); + } + + add_settings_error( + 'igny8_settings', + 'igny8_connected', + __('Successfully connected to IGNY8 API and stored API key.', 'igny8-bridge'), + 'updated' + ); + } + + /** + * Revoke stored API key (secure delete) + * + * Public so tests can call it directly. + */ + public static function revoke_api_key() { + if (function_exists('igny8_delete_secure_option')) { + igny8_delete_secure_option('igny8_api_key'); + igny8_delete_secure_option('igny8_access_token'); + igny8_delete_secure_option('igny8_refresh_token'); + } else { + delete_option('igny8_api_key'); + delete_option('igny8_access_token'); + delete_option('igny8_refresh_token'); + } + + // Also clear token-issued timestamps + delete_option('igny8_token_refreshed_at'); + delete_option('igny8_access_token_issued'); } /** diff --git a/igny8-wp-integration-plugin/admin/settings.php b/igny8-wp-integration-plugin/admin/settings.php index 4cbe9d46..59c6b358 100644 --- a/igny8-wp-integration-plugin/admin/settings.php +++ b/igny8-wp-integration-plugin/admin/settings.php @@ -15,6 +15,7 @@ $email = get_option('igny8_email', ''); $site_id = get_option('igny8_site_id', ''); $access_token = function_exists('igny8_get_secure_option') ? igny8_get_secure_option('igny8_access_token') : get_option('igny8_access_token'); $is_connected = !empty($access_token); +$api_key = function_exists('igny8_get_secure_option') ? igny8_get_secure_option('igny8_api_key') : get_option('igny8_api_key'); $date_format = get_option('date_format'); $time_format = get_option('time_format'); $now = current_time('timestamp'); @@ -53,6 +54,7 @@ $pending_links = array_filter($link_queue, function($item) { return $item['status'] === 'pending'; }); $webhook_logs = igny8_get_webhook_logs(array('limit' => 10)); + $two_way_sync = (int) get_option('igny8_enable_two_way_sync', 1); ?> @@ -60,6 +62,13 @@ $webhook_logs = igny8_get_webhook_logs(array('limit' => 10));

+
+

+
+
+ +

+
@@ -87,6 +96,32 @@ $webhook_logs = igny8_get_webhook_logs(array('limit' => 10));

+ + + + + + +

+ +

+ +
+ + +
+ + + @@ -152,6 +187,23 @@ $webhook_logs = igny8_get_webhook_logs(array('limit' => 10));

+ + + + + + + + diff --git a/igny8-wp-integration-plugin/docs/WORDPRESS-PLUGIN-INTEGRATION.md b/igny8-wp-integration-plugin/docs/WORDPRESS-PLUGIN-INTEGRATION.md index 2b5bb005..cdaed2cf 100644 --- a/igny8-wp-integration-plugin/docs/WORDPRESS-PLUGIN-INTEGRATION.md +++ b/igny8-wp-integration-plugin/docs/WORDPRESS-PLUGIN-INTEGRATION.md @@ -2082,3 +2082,52 @@ if ($result['success']) { **Last Updated**: 2025-11-16 **API Version**: 1.0.0 +--- + +## Site Metadata Endpoint (Bridge) + +The WordPress Bridge exposes a discovery endpoint that the IGNY8 SaaS app can call to retrieve available post types, taxonomies and counts. The endpoint follows the IGNY8 unified response format and includes plugin-side flags so the SaaS app can decide whether to perform automatic syncs. + +- URL: `GET /wp-json/igny8/v1/site-metadata/` +- Authentication: Plugin-side connection must be authenticated. The bridge accepts: + - `Authorization: Bearer ` (preferred) + - `X-IGNY8-API-KEY: ` (supported) + The plugin stores an optional API key in `igny8_api_key` (secure storage via `igny8_store_secure_option()` when available) and will use it as the active access token when present. +- Response shape: IGNY8 unified format — top-level `success`, `data`, optional `message`, and `request_id`. +- Caching: Results are cached on the WP side using a transient (`igny8_site_metadata_v1`) with a default TTL of 300 seconds. + +Behavior notes: +- Toggling "Enable Sync Operations" (option `igny8_connection_enabled`) or "Enable Two-Way Sync" (`igny8_enable_two_way_sync`) in the plugin admin only affects background/inbound/outbound sync actions. REST discovery endpoints remain accessible so the SaaS app can still query metadata even when sync is disabled on either side. +- The endpoint includes two flags in the payload: `plugin_connection_enabled` and `two_way_sync_enabled` so the SaaS app can make informed decisions. + +Example response: + +```json +{ + "success": true, + "data": { + "post_types": { + "post": { "label": "Posts", "count": 123 }, + "page": { "label": "Pages", "count": 12 } + }, + "taxonomies": { + "category": { "label": "Categories", "count": 25 }, + "post_tag": { "label": "Tags", "count": 102 } + }, + "generated_at": 1700553600, + "plugin_connection_enabled": true, + "two_way_sync_enabled": true + }, + "message": "Site metadata retrieved", + "request_id": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +Developer notes: +- Admin settings: + - API key input (option `igny8_api_key`) added to Settings → IGNY8 API. When provided the key is stored securely and used as the access token by the plugin. + - Two-way sync toggle added (option `igny8_enable_two_way_sync`) — default ON. + - Connection toggle `igny8_connection_enabled` remains the master on/off switch for sync operations. +- Tests: Basic unit test added at `igny8-wp-integration-plugin/tests/test-site-metadata.php`. +- Security: Webhook secret and REST permission checks remain enforced; unauthenticated REST calls still return unified 401 errors. + diff --git a/igny8-wp-integration-plugin/includes/class-igny8-api.php b/igny8-wp-integration-plugin/includes/class-igny8-api.php index 5f432a2b..d30291bb 100644 --- a/igny8-wp-integration-plugin/includes/class-igny8-api.php +++ b/igny8-wp-integration-plugin/includes/class-igny8-api.php @@ -32,6 +32,13 @@ class Igny8API { */ private $access_token = null; + /** + * Whether authentication is via API key (true) or tokens (false) + * + * @var bool + */ + private $api_key_auth = false; + /** * Refresh token * @@ -46,9 +53,17 @@ class Igny8API { if (function_exists('igny8_get_secure_option')) { $this->access_token = igny8_get_secure_option('igny8_access_token'); $this->refresh_token = igny8_get_secure_option('igny8_refresh_token'); + $api_key = igny8_get_secure_option('igny8_api_key'); } else { $this->access_token = get_option('igny8_access_token'); $this->refresh_token = get_option('igny8_refresh_token'); + $api_key = get_option('igny8_api_key'); + } + + // If an API key is provided, prefer it as the access token + if (!empty($api_key)) { + $this->access_token = $api_key; + $this->api_key_auth = true; } } diff --git a/igny8-wp-integration-plugin/includes/class-igny8-rest-api.php b/igny8-wp-integration-plugin/includes/class-igny8-rest-api.php index 4ffbb52d..1cffde77 100644 --- a/igny8-wp-integration-plugin/includes/class-igny8-rest-api.php +++ b/igny8-wp-integration-plugin/includes/class-igny8-rest-api.php @@ -69,6 +69,14 @@ class Igny8RestAPI { ) ) )); + + // Site metadata - post types, taxonomies and counts (unified response format) + register_rest_route('igny8/v1', '/site-metadata/', array( + 'methods' => 'GET', + // We perform permission checks inside callback to ensure unified response format + 'callback' => array($this, 'get_site_metadata'), + 'permission_callback' => '__return_true', + )); } /** @@ -78,18 +86,22 @@ class Igny8RestAPI { * @return bool|WP_Error */ public function check_permission($request) { - // Check if connection is enabled - if (!igny8_is_connection_enabled()) { - return new WP_Error( - 'rest_forbidden', - __('IGNY8 connection is disabled', 'igny8-bridge'), - array('status' => 403) - ); - } - - // Check if authenticated with IGNY8 + // Do NOT block endpoints when the plugin connection is disabled. + // The plugin-side "Enable Sync Operations" option should only stop background sync actions, + // but REST discovery endpoints should remain callable. Authentication is still required. + + // Check if authenticated with IGNY8 via stored token OR X-IGNY8-API-KEY header $api = new Igny8API(); - + + // Accept explicit X-IGNY8-API-KEY header for incoming requests + $header_api_key = $request->get_header('x-igny8-api-key'); + if ($header_api_key) { + $stored_api_key = function_exists('igny8_get_secure_option') ? igny8_get_secure_option('igny8_api_key') : get_option('igny8_api_key'); + if ($stored_api_key && hash_equals($stored_api_key, $header_api_key)) { + return true; + } + } + if (!$api->is_authenticated()) { return new WP_Error( 'rest_forbidden', @@ -287,6 +299,126 @@ class Igny8RestAPI { ) )); } + + /** + * Helper: generate a request_id (UUIDv4 if available) + * + * @return string + */ + private function generate_request_id() { + if (function_exists('wp_generate_uuid4')) { + return wp_generate_uuid4(); + } + + // Fallback: uniqid with more entropy + return uniqid('', true); + } + + /** + * Helper: Build unified API response and return WP_REST_Response + * + * @param bool $success + * @param mixed $data + * @param string|null $message + * @param string|null $error + * @param array|null $errors + * @param int $status + * @return WP_REST_Response + */ + private function build_unified_response($success, $data = null, $message = null, $error = null, $errors = null, $status = 200) { + $payload = array( + 'success' => (bool) $success, + 'data' => $data, + 'message' => $message, + 'request_id' => $this->generate_request_id() + ); + + if (!$success) { + $payload['error'] = $error ?: 'Unknown error'; + if (!empty($errors)) { + $payload['errors'] = $errors; + } + } + + $response = rest_ensure_response($payload); + $response->set_status($status); + return $response; + } + + /** + * GET /site-metadata/ - returns post types, taxonomies and counts in unified format + * + * @param WP_REST_Request $request + * @return WP_REST_Response + */ + public function get_site_metadata($request) { + // Use transient cache to avoid expensive counts on large sites + $cache_key = 'igny8_site_metadata_v1'; + $cached = get_transient($cache_key); + if ($cached !== false) { + return $this->build_unified_response(true, $cached, 'Site metadata (cached)', null, null, 200); + } + + // Perform permission check and return unified error if not allowed + $perm = $this->check_permission($request); + if (is_wp_error($perm)) { + $status = 403; + $error_data = $perm->get_error_data(); + if (is_array($error_data) && isset($error_data['status'])) { + $status = intval($error_data['status']); + } + return $this->build_unified_response(false, null, null, $perm->get_error_message(), null, $status); + } + + // Collect post types (public) + $post_types_objects = get_post_types(array('public' => true), 'objects'); + $post_types = array(); + foreach ($post_types_objects as $slug => $obj) { + // Get total count across statuses + $count_obj = wp_count_posts($slug); + $total = 0; + if (is_object($count_obj)) { + foreach (get_object_vars($count_obj) as $val) { + $total += intval($val); + } + } + $post_types[$slug] = array( + 'label' => $obj->labels->singular_name ?? $obj->label, + 'count' => $total + ); + } + + // Collect taxonomies (public) + $taxonomy_objects = get_taxonomies(array('public' => true), 'objects'); + $taxonomies = array(); + foreach ($taxonomy_objects as $slug => $obj) { + // Use wp_count_terms when available + $term_count = 0; + if (function_exists('wp_count_terms')) { + $term_count = intval(wp_count_terms($slug)); + } else { + $terms = get_terms(array('taxonomy' => $slug, 'hide_empty' => false, 'fields' => 'ids')); + $term_count = is_array($terms) ? count($terms) : 0; + } + + $taxonomies[$slug] = array( + 'label' => $obj->labels->name ?? $obj->label, + 'count' => $term_count + ); + } + + $data = array( + 'post_types' => $post_types, + 'taxonomies' => $taxonomies, + 'generated_at' => time(), + 'plugin_connection_enabled' => (bool) igny8_is_connection_enabled(), + 'two_way_sync_enabled' => (bool) get_option('igny8_enable_two_way_sync', 1) + ); + // Cache for 5 minutes + set_transient($cache_key, $data, 300); + + return $this->build_unified_response(true, $data, 'Site metadata retrieved', null, null, 200); + } } // Initialize REST API diff --git a/igny8-wp-integration-plugin/tests/test-revoke-api-key.php b/igny8-wp-integration-plugin/tests/test-revoke-api-key.php new file mode 100644 index 00000000..7bc2932b --- /dev/null +++ b/igny8-wp-integration-plugin/tests/test-revoke-api-key.php @@ -0,0 +1,28 @@ +assertFalse(get_option('igny8_api_key')); + $this->assertFalse(get_option('igny8_access_token')); + $this->assertFalse(get_option('igny8_refresh_token')); + $this->assertFalse(get_option('igny8_token_refreshed_at')); + } +} + + diff --git a/igny8-wp-integration-plugin/tests/test-site-metadata.php b/igny8-wp-integration-plugin/tests/test-site-metadata.php new file mode 100644 index 00000000..ba793f10 --- /dev/null +++ b/igny8-wp-integration-plugin/tests/test-site-metadata.php @@ -0,0 +1,36 @@ +set_header('Authorization', 'Bearer test-api-key-123'); + + $server = rest_get_server(); + $response = $server->dispatch($request); + + $this->assertEquals(200, $response->get_status()); + $data = $response->get_data(); + $this->assertNotEmpty($data); + $this->assertArrayHasKey('success', $data); + $this->assertTrue($data['success']); + $this->assertArrayHasKey('data', $data); + $this->assertArrayHasKey('post_types', $data['data']); + $this->assertArrayHasKey('taxonomies', $data['data']); + } +} + + diff --git a/master-docs/WORDPRESS-PLUGIN-INTEGRATION.md b/master-docs/WORDPRESS-PLUGIN-INTEGRATION.md index e25eab33..dba2364b 100644 --- a/master-docs/WORDPRESS-PLUGIN-INTEGRATION.md +++ b/master-docs/WORDPRESS-PLUGIN-INTEGRATION.md @@ -2072,3 +2072,52 @@ if ($result['success']) { **Last Updated**: 2025-11-16 **API Version**: 1.0.0 +--- + +## Site Metadata Endpoint (Bridge) + +The WordPress Bridge exposes a discovery endpoint that the IGNY8 SaaS app can call to retrieve available post types, taxonomies and counts. The endpoint follows the IGNY8 unified response format and includes plugin-side flags so the SaaS app can decide whether to perform automatic syncs. + +- URL: `GET /wp-json/igny8/v1/site-metadata/` +- Authentication: Plugin-side connection must be authenticated. The bridge accepts: + - `Authorization: Bearer ` (preferred) + - `X-IGNY8-API-KEY: ` (supported) + The plugin stores an optional API key in `igny8_api_key` (secure storage via `igny8_store_secure_option()` when available) and will use it as the active access token when present. +- Response shape: IGNY8 unified format — top-level `success`, `data`, optional `message`, and `request_id`. +- Caching: Results are cached on the WP side using a transient (`igny8_site_metadata_v1`) with a default TTL of 300 seconds. + +Behavior notes: +- Toggling "Enable Sync Operations" (option `igny8_connection_enabled`) or "Enable Two-Way Sync" (`igny8_enable_two_way_sync`) in the plugin admin only affects background/inbound/outbound sync actions. REST discovery endpoints remain accessible so the SaaS app can still query metadata even when sync is disabled on either side. +- The endpoint includes two flags in the payload: `plugin_connection_enabled` and `two_way_sync_enabled` so the SaaS app can make informed decisions. + +Example response: + +```json +{ + "success": true, + "data": { + "post_types": { + "post": { "label": "Posts", "count": 123 }, + "page": { "label": "Pages", "count": 12 } + }, + "taxonomies": { + "category": { "label": "Categories", "count": 25 }, + "post_tag": { "label": "Tags", "count": 102 } + }, + "generated_at": 1700553600, + "plugin_connection_enabled": true, + "two_way_sync_enabled": true + }, + "message": "Site metadata retrieved", + "request_id": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +Developer notes: +- Admin settings: + - API key input (option `igny8_api_key`) added to Settings → IGNY8 API. When provided the key is stored securely and used as the access token by the plugin. + - Two-way sync toggle added (option `igny8_enable_two_way_sync`) — default ON. + - Connection toggle `igny8_connection_enabled` remains the master on/off switch for sync operations. +- Tests: Basic unit test added at `igny8-wp-integration-plugin/tests/test-site-metadata.php`. +- Security: Webhook secret and REST permission checks remain enforced; unauthenticated REST calls still return unified 401 errors. +