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.
This commit is contained in:
IGNY8 VPS (Salman)
2025-11-21 15:18:48 +00:00
parent 1eba4a4e15
commit c35b3c3641
10 changed files with 475 additions and 34 deletions

Binary file not shown.

View File

@@ -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

View File

@@ -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,23 +198,45 @@ class Igny8Admin {
private function handle_connection() {
$email = sanitize_email($_POST['igny8_email'] ?? '');
$password = $_POST['igny8_password'] ?? '';
$api_key = sanitize_text_field($_POST['igny8_api_key'] ?? '');
if (empty($email) || empty($password)) {
// 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)) {
if (!$api->login($email, $password)) {
add_settings_error(
'igny8_settings',
'igny8_error',
__('Failed to connect to IGNY8 API with provided credentials.', 'igny8-bridge'),
'error'
);
return;
}
// Store email
update_option('igny8_email', $email);
// Try to get site ID (if available)
// 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];
@@ -208,17 +246,30 @@ class Igny8Admin {
add_settings_error(
'igny8_settings',
'igny8_connected',
__('Successfully connected to IGNY8 API.', 'igny8-bridge'),
__('Successfully connected to IGNY8 API and stored API key.', 'igny8-bridge'),
'updated'
);
} else {
add_settings_error(
'igny8_settings',
'igny8_error',
__('Failed to connect to IGNY8 API. Please check your credentials.', 'igny8-bridge'),
'error'
);
}
/**
* 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');
}
/**

View File

@@ -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));
<h1><?php echo esc_html(get_admin_page_title()); ?></h1>
<?php settings_errors('igny8_settings'); ?>
<div class="notice notice-info inline" style="margin-top:10px;">
<p>
<strong><?php _e('Integration modes explained:', 'igny8-bridge'); ?></strong><br />
<?php _e('• Enable Sync Operations: controls whether background and manual sync actions occur (cron jobs, webhooks, sync buttons).', 'igny8-bridge'); ?><br />
<?php _e('• Enable Two-Way Sync: controls whether bi-directional syncing (IGNY8 → WordPress and WordPress → IGNY8) is permitted. Disabling this will suppress sync actions but API endpoints remain accessible for discovery and diagnostics.', 'igny8-bridge'); ?>
</p>
</div>
<div class="igny8-settings-container">
<div class="igny8-settings-card">
@@ -87,6 +96,32 @@ $webhook_logs = igny8_get_webhook_logs(array('limit' => 10));
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="igny8_api_key"><?php _e('API Key', 'igny8-bridge'); ?></label>
</th>
<td>
<input
type="password"
id="igny8_api_key"
name="igny8_api_key"
value="<?php echo esc_attr($api_key ? '********' : ''); ?>"
class="regular-text"
placeholder="<?php _e('Paste your IGNY8 API key here (optional)', 'igny8-bridge'); ?>"
/>
<p class="description">
<?php _e('If you have an API key from the IGNY8 SaaS app, paste it here to authenticate the bridge. Leave blank to use email/password.', 'igny8-bridge'); ?>
</p>
<?php if ($api_key) : ?>
<form method="post" action="" style="display:inline-block; margin-left:10px;">
<?php wp_nonce_field('igny8_revoke_api_key'); ?>
<button type="submit" name="igny8_revoke_api_key" class="button button-secondary" onclick="return confirm('<?php _e('Revoke stored API key? This will remove the key from this site.', 'igny8-bridge'); ?>');">
<?php _e('Revoke API Key', 'igny8-bridge'); ?>
</button>
</form>
<?php endif; ?>
</td>
</tr>
<tr>
<th scope="row">
<label for="igny8_password"><?php _e('Password', 'igny8-bridge'); ?></label>
@@ -152,6 +187,23 @@ $webhook_logs = igny8_get_webhook_logs(array('limit' => 10));
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="igny8_enable_two_way_sync"><?php _e('Enable Two-Way Sync', 'igny8-bridge'); ?></label>
</th>
<td>
<label>
<input
type="checkbox"
id="igny8_enable_two_way_sync"
name="igny8_enable_two_way_sync"
value="1"
<?php checked($two_way_sync, 1); ?>
/>
<?php _e('Allow bi-directional sync (IGNY8 ↔ WordPress). When disabled, outbound/inbound sync actions are suppressed but API endpoints remain accessible.', 'igny8-bridge'); ?>
</label>
</td>
</tr>
<?php if ($email) : ?>
<tr>
<th scope="row"><?php _e('Email', 'igny8-bridge'); ?></th>

View File

@@ -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 <token_or_api_key>` (preferred)
- `X-IGNY8-API-KEY: <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.

View File

@@ -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;
}
}

View File

@@ -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)
);
}
// 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
// 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

View File

@@ -0,0 +1,28 @@
<?php
/**
* Unit test for API key revoke handler
*
* @package Igny8Bridge
*/
class Test_Revoke_Api_Key extends WP_UnitTestCase {
public function test_revoke_api_key_clears_options() {
// Simulate stored API key and tokens
update_option('igny8_api_key', 'test-key-123');
update_option('igny8_access_token', 'test-key-123');
update_option('igny8_refresh_token', 'refresh-123');
update_option('igny8_token_refreshed_at', time());
// Call revoke
Igny8Admin::revoke_api_key();
// Assert removed
$this->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'));
}
}

View File

@@ -0,0 +1,36 @@
<?php
/**
* Basic unit test for site-metadata endpoint
*
* @package Igny8Bridge
*/
class Test_Site_Metadata_Endpoint extends WP_UnitTestCase {
public function test_site_metadata_endpoint_returns_success() {
// Ensure connection enabled
update_option('igny8_connection_enabled', 1);
// Create a fake API key so permission checks pass via Igny8API
update_option('igny8_api_key', 'test-api-key-123');
update_option('igny8_access_token', 'test-api-key-123');
// Build request
$request = new WP_REST_Request('GET', '/igny8/v1/site-metadata/');
$request->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']);
}
}

View File

@@ -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 <token_or_api_key>` (preferred)
- `X-IGNY8-API-KEY: <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.