diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index bd297af..0000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,38 +0,0 @@
-# Changelog
-
-All notable changes to OpenGraph (by ROBOTSTXT) will be documented in this file.
-
-The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
-and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-
-## [1.0.0] - 2026-02-18
-
-### Added
-- Automatic format detection for AVIF, WebP, GIF, BMP, SVG, and TIFF featured images
-- HTTP HEAD-based resolution of compatible JPEG/PNG alternatives
-- Postmeta caching (`_og_image_fallback_url`) with automatic invalidation on featured image change
-- Negative caching via transients (1-hour TTL) to avoid repeated failed HEAD requests
-- Open Graph meta tags: `og:image`, `og:image:secure_url`, `og:image:width`, `og:image:height`, `og:image:type`
-- Direct `wp_head` tag injection (when no SEO plugin is active)
-- Yoast SEO integration via `wpseo_opengraph_image` filter
-- RankMath integration via `rank_math/opengraph/facebook/og_image` filter
-- Global fallback image setting (configurable in Settings > OpenGraph)
-- Taxonomy archive image support via `robotstxt_og_taxonomy_image` filter
-- Admin settings page (Settings > OpenGraph) with three tabs: Settings, Tools, Diagnostics
-- WP-CLI commands: `wp og-fallback resolve` and `wp og-fallback clear-cache`
-- REST API: `POST /wp-json/robotstxt-og/v1/resolve/{post_id}` and `GET /wp-json/robotstxt-og/v1/status/{post_id}`
-- Developer filters: `robotstxt_og_external_image_enabled`, `robotstxt_og_external_image_timeout`, `robotstxt_og_taxonomy_image`, `robotstxt_og_enable_logging`
-- Auto-updater via Gitea (`robotstxt-updater.php`)
-- Multisite compatible with per-site configuration
-- Full internationalization support (text domain: `robotstxt-og`)
-- `uninstall.php` with optional data deletion on plugin removal
-
-### Security
-- Nonce verification on all forms and state-changing actions
-- `manage_options` capability check on all admin pages, REST endpoints, and WP-CLI commands
-- Input sanitization via WordPress APIs throughout
-- Output escaping on all dynamic HTML
-- WordPress database APIs exclusively (no direct SQL writes; `$wpdb->prepare()` for reads)
-- CSRF protection on all state-changing actions
-
-[1.0.0]: https://git.robotstxt.es/ROBOTSTXT/robotstxt-og/releases/tag/1.0.0
diff --git a/README.md b/README.md
deleted file mode 100644
index 1838829..0000000
--- a/README.md
+++ /dev/null
@@ -1,132 +0,0 @@
-# OpenGraph (by ROBOTSTXT)
-
-> Intelligent Open Graph image fallback for social media crawlers.
-
-Automatically detects when a post's featured image uses a modern format (AVIF, WebP) that social media crawlers cannot render, and resolves a compatible JPEG/PNG alternative to use in `og:image` meta tags.
-
-## Requirements
-
-- **WordPress:** 6.7+
-- **PHP:** 8.2–8.5
-- **WP-CLI:** 2.x (optional, for CLI tools)
-
-## Installation
-
-```bash
-# From the plugin directory
-composer install
-```
-
-Activate the plugin through the WordPress admin or WP-CLI:
-
-```bash
-wp plugin activate robotstxt-og --allow-root
-```
-
-## Architecture
-
-```
-robotstxt-og/
-├── robotstxt-og.php # Main plugin file, constants, bootstrap
-├── includes/
-│ ├── class-robotstxt-og-image-fallback.php # Singleton, orchestration
-│ ├── class-robotstxt-og-image-resolver.php # Image detection, HEAD requests, caching
-│ ├── class-robotstxt-og-tags.php # OG tag injection, SEO plugin filters
-│ ├── class-robotstxt-og-cli.php # WP-CLI commands
-│ └── class-robotstxt-og-rest-api.php # REST API endpoints
-├── admin/
-│ ├── class-robotstxt-og-admin-settings.php # Admin page, settings, cache actions
-│ └── views/settings-page.php # Settings page template (tabbed)
-├── assets/
-│ ├── admin.js # Media uploader integration
-│ └── admin.css # Admin styles
-├── languages/
-│ └── robotstxt-og.pot # Translation template
-├── docs/
-│ ├── FILTERS-HOOKS.md # Developer filter/hook reference
-│ ├── WP-CLI.md # WP-CLI command reference
-│ └── IDEA.md # Original concept document
-├── bin/
-│ └── deploy.sh # Build and ZIP packaging script
-├── robotstxt-updater.php # Auto-updater (Gitea-based)
-├── uninstall.php # Data cleanup on uninstall
-├── readme.txt # WordPress.org plugin readme
-└── changelog.txt # Full changelog (WordPress.org format)
-```
-
-## Development
-
-### Code Standards
-
-```bash
-# Lint
-vendor/bin/phpcs .
-
-# Auto-fix
-vendor/bin/phpcbf .
-
-# PHP compatibility check (8.2–8.5)
-vendor/bin/phpcs -p . --standard=PHPCompatibility --runtime-set testVersion 8.2-8.5
-```
-
-### Regenerate translation template
-
-```bash
-wp i18n make-pot . languages/robotstxt-og.pot --domain=robotstxt-og --exclude=vendor,node_modules,tests --allow-root
-```
-
-### Build release ZIP
-
-```bash
-bash bin/deploy.sh
-```
-
-The ZIP is created at `../robotstxt-og-{version}.zip` (i.e. in `wp-content/plugins/`).
-
-## WP-CLI Commands
-
-```bash
-# Resolve fallback for a single post
-wp og-fallback resolve 123
-
-# Re-resolve all posts with featured images
-wp og-fallback resolve --all
-
-# Dry-run (no changes saved)
-wp og-fallback resolve --all --dry-run
-
-# Clear cache for a single post
-wp og-fallback clear-cache 123
-
-# Clear all caches
-wp og-fallback clear-cache --all
-```
-
-## REST API
-
-All endpoints require the `manage_options` capability.
-
-| Method | Endpoint | Description |
-|--------|----------|-------------|
-| `POST` | `/wp-json/robotstxt-og/v1/resolve/{post_id}` | Force re-resolve fallback image |
-| `GET` | `/wp-json/robotstxt-og/v1/status/{post_id}` | Get current cache status |
-
-## Filters
-
-| Filter | Default | Description |
-|--------|---------|-------------|
-| `robotstxt_og_external_image_enabled` | `true` | Enable/disable external image resolution |
-| `robotstxt_og_external_image_timeout` | `5` | HEAD request timeout (seconds) |
-| `robotstxt_og_taxonomy_image` | `''` | Image URL for taxonomy archive pages |
-| `robotstxt_og_enable_logging` | `false` | Enable debug logging to `debug.log` |
-
-Full documentation: [`docs/FILTERS-HOOKS.md`](docs/FILTERS-HOOKS.md)
-
-## License
-
-GPL-3.0-or-later — see [LICENSE](https://www.gnu.org/licenses/gpl-3.0.txt)
-
-## Author
-
-**ROBOTSTXT** — [robotstxt.es](https://www.robotstxt.es/)
-Contributors: javiercasares, robotstxt
diff --git a/admin/class-robotstxt-og-admin-settings.php b/admin/class-robotstxt-og-admin-settings.php
index bcec130..540a298 100644
--- a/admin/class-robotstxt-og-admin-settings.php
+++ b/admin/class-robotstxt-og-admin-settings.php
@@ -54,6 +54,24 @@ class Robotstxt_OG_Admin_Settings {
add_action( 'admin_init', array( $this, 'handle_resolve_all' ) );
add_action( 'admin_init', array( $this, 'handle_clear_single' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) );
+
+ // Allow editors (edit_others_posts) to save settings via the Settings API.
+ // options.php defaults to manage_options; this filter lowers it for our group.
+ add_filter( 'option_page_capability_robotstxt_og_settings', array( $this, 'get_settings_capability' ) );
+ }
+
+ /**
+ * Return the capability required to save plugin settings.
+ *
+ * Allows users with edit_others_posts (editors and above) to save plugin
+ * settings through the WordPress Settings API (options.php).
+ *
+ * @since 1.0.3
+ *
+ * @return string Capability slug.
+ */
+ public function get_settings_capability(): string {
+ return 'edit_others_posts';
}
/**
@@ -77,7 +95,7 @@ class Robotstxt_OG_Admin_Settings {
}
// Verify nonce.
- $nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_SANITIZE_SPECIAL_CHARS );
+ $nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
$nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : '';
if ( ! wp_verify_nonce( $nonce, 'robotstxt_og_clear_single_' . $post_id ) ) {
@@ -85,7 +103,7 @@ class Robotstxt_OG_Admin_Settings {
}
// Check permissions.
- if ( ! current_user_can( 'manage_options' ) ) {
+ if ( ! current_user_can( 'edit_others_posts' ) ) {
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-og' ) );
}
@@ -120,7 +138,7 @@ class Robotstxt_OG_Admin_Settings {
add_options_page(
__( 'OpenGraph Settings', 'robotstxt-og' ),
__( 'OpenGraph', 'robotstxt-og' ),
- 'manage_options',
+ 'edit_others_posts',
'robotstxt-og-settings',
array( $this, 'render_settings_page' )
);
@@ -344,7 +362,8 @@ class Robotstxt_OG_Admin_Settings {
* @return void
*/
public function render_fallback_image_field(): void {
- $image_url = (string) get_option( 'robotstxt_og_fallback_image', '' );
+ $option_value = get_option( 'robotstxt_og_fallback_image', '' );
+ $image_url = is_string( $option_value ) ? $option_value : '';
?>
' . esc_html__( 'Value', 'robotstxt-og' ) . '
';
foreach ( $headers as $header_key => $header_value ) {
echo '' . esc_html( $header_key ) . ' ';
- echo '' . esc_html( is_array( $header_value ) ? implode( ', ', $header_value ) : $header_value ) . ' ';
+ if ( is_array( $header_value ) ) {
+ $display_value = implode( ', ', array_map( static fn( $v ) => is_scalar( $v ) ? (string) $v : '', $header_value ) );
+ } elseif ( is_string( $header_value ) ) {
+ $display_value = $header_value;
+ } else {
+ $display_value = '';
+ }
+ echo '' . esc_html( $display_value ) . ' ';
}
echo ' ';
}
@@ -324,7 +334,7 @@ $page_url = admin_url( 'options-general.php?page=robotstxt-og-settings' );
post_id ); ?>
-
+
post_title ); ?>
diff --git a/changelog.txt b/changelog.txt
index f0e123e..989399e 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -1,18 +1,39 @@
== Changelog ==
-= 1.0.1 =
+= 1.1.0 =
-_Release date: 2026-03-05_
+_Release date: 2026-03-28_
-**Compatibility**
+**Fixed**
-* Confirmed compatibility with WordPress 7.0. Supported range is now WordPress 6.7 – 7.0.
+* Fatal `TypeError` in `handle_thumbnail_change()`: the `deleted_post_meta` action passes an array of meta IDs as its first argument (unlike `updated_post_meta`, which passes a single `int`). The method signature now accepts `int|array` to handle both actions correctly.
+
+**Security**
+
+* Added SSRF protection in `Robotstxt_OG_Image_Resolver::is_safe_url()`: all outbound HTTP HEAD requests now validate that the target host resolves to a public IP address. Private (RFC 1918), loopback, link-local, and reserved ranges are blocked.
+* Replaced deprecated `FILTER_SANITIZE_SPECIAL_CHARS` with `FILTER_SANITIZE_FULL_SPECIAL_CHARS` in nonce and input handling throughout admin and meta box classes.
+
+**Added**
+
+* GDPR Privacy API: registered `wp_privacy_personal_data_exporters` and `wp_privacy_personal_data_erasers` filters. Custom `_og_title` and `_og_description` post meta are now included in WordPress personal data export and erase requests.
+* PHPUnit 10 test suite using Brain\Monkey for WordPress function mocking. Covers SSRF IP validation, image format detection (`ensure_compatible_format()`), external URL detection, and cache clearing. No WordPress installation required to run tests (`vendor/bin/phpunit`).
+
+**Changed**
+
+* Settings page and REST API permission check changed from `manage_options` to `edit_others_posts`. Editors and above can now access OG settings and trigger fallback image resolution. The WordPress Settings API form is unlocked via the `option_page_capability_robotstxt_og_settings` filter.
+* REST API `check_permission()` now also verifies `edit_post` for the specific post ID requested.
+* Minimum supported WordPress version raised from 6.7 to 6.8 (two previous major versions back from WordPress 7.0).
+* Added `Network: true` plugin header, formally documenting Multisite compatibility.
**Tests**
+* WordPress: 6.8, 6.9, 7.0
+* PHP: 8.2, 8.5
* PHP_CodeSniffer: 3.13.5
* WordPress Coding Standards: 3.3.0
* PHPCompatibility: 9.3.5
+* PHPStan: level 9
+* PHPUnit: 10.5
= 1.0.0 =
diff --git a/docs/FILTERS-HOOKS.md b/docs/FILTERS-HOOKS.md
deleted file mode 100644
index 51a8ac3..0000000
--- a/docs/FILTERS-HOOKS.md
+++ /dev/null
@@ -1,240 +0,0 @@
-# Filters & Hooks Reference
-
-Developer reference for all filters and actions provided by the **OpenGraph (by ROBOTSTXT)** plugin.
-
-## Table of Contents
-
-- [Filters](#filters)
- - [robotstxt_og_external_image_enabled](#robotstxt_og_external_image_enabled)
- - [robotstxt_og_external_image_timeout](#robotstxt_og_external_image_timeout)
- - [robotstxt_og_taxonomy_image](#robotstxt_og_taxonomy_image)
- - [robotstxt_og_enable_logging](#robotstxt_og_enable_logging)
-- [Actions](#actions)
-- [SEO Plugin Integrations](#seo-plugin-integrations)
-
----
-
-## Filters
-
-### `robotstxt_og_external_image_enabled`
-
-Controls whether the plugin attempts to resolve fallback images for external URLs (images hosted on a different domain than the WordPress site).
-
-**Default:** `true`
-
-**Parameters:**
-
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| `$enabled` | `bool` | Whether external image resolution is enabled. |
-| `$image_url` | `string` | The external image URL being evaluated. |
-
-**Returns:** `bool`
-
-**Example — disable external image resolution entirely:**
-
-```php
-add_filter( 'robotstxt_og_external_image_enabled', '__return_false' );
-```
-
-**Example — disable only for a specific CDN domain:**
-
-```php
-add_filter( 'robotstxt_og_external_image_enabled', function ( bool $enabled, string $image_url ): bool {
- if ( str_contains( $image_url, 'cdn.example.com' ) ) {
- return false;
- }
- return $enabled;
-}, 10, 2 );
-```
-
----
-
-### `robotstxt_og_external_image_timeout`
-
-Sets the HTTP request timeout (in seconds) used when verifying whether a fallback image URL exists via a HEAD request.
-
-**Default:** `5` (seconds)
-
-**Parameters:**
-
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| `$timeout` | `int` | Timeout in seconds for the HEAD request. |
-| `$url` | `string` | The image URL being tested. |
-
-**Returns:** `int`
-
-**Example — increase timeout for slow external servers:**
-
-```php
-add_filter( 'robotstxt_og_external_image_timeout', function ( int $timeout, string $url ): int {
- if ( str_contains( $url, 'slow-cdn.example.com' ) ) {
- return 15;
- }
- return $timeout;
-}, 10, 2 );
-```
-
-**Example — set a global lower timeout for performance:**
-
-```php
-add_filter( 'robotstxt_og_external_image_timeout', function (): int {
- return 3;
-} );
-```
-
----
-
-### `robotstxt_og_taxonomy_image`
-
-Provides a fallback OG image URL for taxonomy archive pages (categories, tags, custom taxonomies). By default, taxonomy archives do not have a featured image, so this filter is the primary way to supply one.
-
-**Default:** `''` (empty string — no image)
-
-**Parameters:**
-
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| `$image_url` | `string` | Image URL to use. Empty string by default. |
-| `$term_id` | `int` | The term ID of the current taxonomy archive. |
-
-**Returns:** `string` A valid image URL, or empty string to skip.
-
-**Example — use a custom field set on the term:**
-
-```php
-add_filter( 'robotstxt_og_taxonomy_image', function ( string $image_url, int $term_id ): string {
- $custom_image_id = get_term_meta( $term_id, 'og_image_id', true );
-
- if ( $custom_image_id ) {
- $url = wp_get_attachment_url( (int) $custom_image_id );
- return $url ? $url : $image_url;
- }
-
- return $image_url;
-}, 10, 2 );
-```
-
-**Example — use a WooCommerce category thumbnail:**
-
-```php
-add_filter( 'robotstxt_og_taxonomy_image', function ( string $image_url, int $term_id ): string {
- $thumbnail_id = get_term_meta( $term_id, 'thumbnail_id', true );
-
- if ( $thumbnail_id ) {
- $url = wp_get_attachment_url( (int) $thumbnail_id );
- return $url ? $url : $image_url;
- }
-
- return $image_url;
-}, 10, 2 );
-```
-
----
-
-### `robotstxt_og_enable_logging`
-
-Enables or disables debug logging to `wp-content/debug.log`. When enabled, resolution events (cache hits, cache misses, format detection, HEAD request results) are written to the error log.
-
-**Default:** `false`
-
-**Parameters:**
-
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| `$enabled` | `bool` | Whether debug logging is active. |
-
-**Returns:** `bool`
-
-**Note:** Requires `WP_DEBUG` and `WP_DEBUG_LOG` to be enabled in `wp-config.php` for output to appear in `debug.log`.
-
-**Example — enable logging (e.g. during development, in `wp-config.php`):**
-
-```php
-// wp-config.php
-define( 'WP_DEBUG', true );
-define( 'WP_DEBUG_LOG', true );
-```
-
-```php
-// functions.php or a mu-plugin
-add_filter( 'robotstxt_og_enable_logging', '__return_true' );
-```
-
-**Example — enable logging only for specific users:**
-
-```php
-add_filter( 'robotstxt_og_enable_logging', function ( bool $enabled ): bool {
- return current_user_can( 'manage_options' ) ? true : $enabled;
-} );
-```
-
----
-
-## Actions
-
-The plugin does not currently expose custom action hooks. WordPress core hooks used internally include:
-
-| Hook | Context | Purpose |
-|------|---------|---------|
-| `plugins_loaded` | Global | Loads text domain for translations. |
-| `wp_head` | Frontend | Injects `og:image` meta tags (only when no SEO plugin is active). |
-| `admin_menu` | Admin | Registers the Settings > OpenGraph settings page. |
-| `admin_init` | Admin | Registers settings, handles cache clear/resolve actions. |
-| `admin_enqueue_scripts` | Admin | Enqueues media uploader and admin CSS/JS. |
-| `rest_api_init` | REST API | Registers the `robotstxt-og/v1` REST endpoints. |
-| `updated_post_meta` | Global | Auto-clears fallback cache when `_thumbnail_id` changes. |
-| `deleted_post_meta` | Global | Auto-clears fallback cache when `_thumbnail_id` is removed. |
-
----
-
-## SEO Plugin Integrations
-
-When a supported SEO plugin is detected, the plugin switches from direct `og:image` tag injection to filtering the SEO plugin's output. This prevents duplicate meta tags.
-
-### Yoast SEO
-
-**Filter:** `wpseo_opengraph_image`
-
-When Yoast SEO is active (`WPSEO_VERSION` is defined), the plugin hooks into this filter to provide the resolved fallback image. The plugin only overrides the value if a valid fallback URL is resolved; otherwise it returns the original Yoast value unchanged.
-
-### RankMath
-
-**Filter:** `rank_math/opengraph/facebook/og_image`
-
-When RankMath is active (`RankMath` class exists), the plugin hooks into this filter with the same logic as the Yoast integration.
-
-### Adding Support for Other SEO Plugins
-
-To integrate with another SEO plugin, hook into the plugin's OG image filter and call the resolver manually:
-
-```php
-add_filter( 'your_seo_plugin_og_image_filter', function ( string $image ) : string {
- if ( ! is_singular() ) {
- return $image;
- }
-
- $post_id = get_queried_object_id();
- $resolver = Robotstxt_OG_Image_Fallback::get_instance()->get_resolver();
- $fallback = $resolver->get_fallback_image( $post_id );
-
- return ! empty( $fallback ) ? $fallback : $image;
-} );
-```
-
----
-
-## Postmeta Keys (Internal Cache)
-
-These postmeta keys are used internally for caching and should not be modified directly:
-
-| Meta Key | Type | Description |
-|----------|------|-------------|
-| `_og_image_fallback_url` | `string` | Cached resolved fallback image URL for a post. |
-
-These transient keys are used for negative caching (failed HEAD request results):
-
-| Transient Key Pattern | TTL | Description |
-|-----------------------|-----|-------------|
-| `robotstxt_og_miss_{md5_of_url}` | 1 hour | Marks a URL as unreachable to prevent repeated requests. |
diff --git a/docs/OG-TWITTER-REFERENCE.md b/docs/OG-TWITTER-REFERENCE.md
deleted file mode 100644
index c6a9e74..0000000
--- a/docs/OG-TWITTER-REFERENCE.md
+++ /dev/null
@@ -1,183 +0,0 @@
-# Open Graph & Twitter Cards — Reference
-
-This document covers the Open Graph (OG) and Twitter Cards meta tag specifications relevant to this plugin, including which tags are mandatory, recommended, optional, and which this plugin outputs automatically.
-
----
-
-## Open Graph Protocol
-
-Defined by Facebook/Meta. All OG tags use the `property` attribute.
-
-### Core Tags (og: namespace)
-
-| Property | Type | Required | Notes |
-|---|---|---|---|
-| `og:title` | string | **Required** | Title of the content. Used by all social crawlers. |
-| `og:type` | string | **Required** | Content type: `website`, `article`, `video.movie`, etc. |
-| `og:url` | URL | **Required** | Canonical URL of the page. |
-| `og:description` | string | Recommended | Short description (2–4 sentences). Max ~300 chars. |
-| `og:site_name` | string | Recommended | Name of the overall site (e.g. "My Blog"). |
-| `og:locale` | string | Recommended | Locale in `language_TERRITORY` format (e.g. `es_ES`). |
-| `og:image` | URL | **Required** (for cards) | Must be JPEG or PNG for social crawler compatibility. Min 200×200 px. Recommended 1200×630 px. |
-| `og:image:secure_url` | URL | Optional | HTTPS version of `og:image`. Same value when the site is HTTPS-only. |
-| `og:image:type` | MIME type | Recommended | MIME type of the image (`image/jpeg`, `image/png`). |
-| `og:image:width` | integer | Recommended | Width in pixels. Avoids reflow in crawler previews. |
-| `og:image:height` | integer | Recommended | Height in pixels. |
-| `og:image:alt` | string | Recommended | Alt text for the image. Required for accessibility audits. |
-
-### Article Tags (article: namespace)
-
-Used when `og:type = article`. All are optional but recommended for news/blog content.
-
-| Property | Type | Notes |
-|---|---|---|
-| `article:published_time` | ISO 8601 datetime | Publication date (`c` format in PHP: `get_the_date('c')`). |
-| `article:modified_time` | ISO 8601 datetime | Last modification date. |
-| `article:author` | URL | Profile page of the author (Facebook profile URL). Often omitted. |
-| `article:section` | string | Primary category or section (e.g. "Technology"). |
-| `article:tag` | string | Topic tags. Can be repeated once per tag. |
-| `article:expiration_time` | ISO 8601 datetime | When the article expires (rarely used). |
-
-### Video Tags (video: namespace)
-
-For `og:type = video.movie`, `video.episode`, etc. Out of scope for this plugin.
-
----
-
-## Twitter Cards
-
-Defined by X (formerly Twitter). Tags use the `name` attribute (not `property`).
-
-Twitter falls back to `og:*` tags if the corresponding `twitter:*` tag is absent — **except** `twitter:card`, which is always required.
-
-### Card Types
-
-| Value | Description |
-|---|---|
-| `summary` | Small square image (minimum 144×144 px). |
-| `summary_large_image` | Large rectangular image (minimum 300×157 px, recommended 1200×628 px). Most common for blog/news content. |
-| `app` | Promotes a mobile app. |
-| `player` | Embeds a video/audio player. |
-
-### Twitter Tags
-
-| Name | Required | Falls back to | Notes |
-|---|---|---|---|
-| `twitter:card` | **Required** | — | Must always be present. Without it, no Twitter Card is shown. |
-| `twitter:site` | Recommended | — | `@username` of the site's Twitter/X account. |
-| `twitter:creator` | Optional | — | `@username` of the content author. |
-| `twitter:title` | Recommended | `og:title` | Title of the content. |
-| `twitter:description` | Recommended | `og:description` | Description. Max 200 chars. |
-| `twitter:image` | Recommended | `og:image` | Must be JPEG, PNG, WebP, or GIF. Max 5 MB. |
-| `twitter:image:alt` | Recommended | `og:image:alt` | Alt text for the image. Max 420 chars. |
-
----
-
-## What This Plugin Outputs
-
-### When no SEO plugin is active (direct injection)
-
-The plugin outputs a **complete** set of OG and Twitter Card tags via `wp_head` (priority 5).
-
-#### Open Graph tags
-
-```html
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-```
-
-#### Twitter Card tags
-
-```html
-
-
-
-```
-
-> Twitter falls back to `og:title`, `og:description`, and `og:image` automatically, so those tags are not duplicated.
-
-### When Yoast SEO or RankMath is active
-
-The plugin acts as a **corrector only**: it filters the image URL via the SEO plugin's filter hook, converting incompatible formats (AVIF/WebP) to JPEG/PNG. All other OG/Twitter tags are managed by the SEO plugin.
-
----
-
-## og:type Values Reference
-
-| Value | When to use |
-|---|---|
-| `website` | Default for homepages and most pages. |
-| `article` | Blog posts, news articles. This plugin uses this for `is_singular('post')`. |
-| `profile` | User profile pages. |
-| `video.movie` | Movie pages. |
-| `video.episode` | TV episode pages. |
-| `music.song` | Song pages. |
-| `music.album` | Album pages. |
-
----
-
-## Per-Post Overrides (Editor Meta Box)
-
-The plugin adds an **"Open Graph / Social Media"** meta box to all post editors, allowing per-post overrides of:
-
-| Field | OG Tag | Fallback |
-|---|---|---|
-| Custom Title | `og:title` | Post title (`get_the_title()`) |
-| Custom Description | `og:description` | Post excerpt, or empty |
-
-These overrides are stored as post meta:
-- `_og_title` — custom OG title
-- `_og_description` — custom OG description
-
----
-
-## Context → og:type Mapping (this plugin)
-
-| WordPress context | `og:type` |
-|---|---|
-| `is_singular('post')` | `article` |
-| `is_singular('page')` | `website` |
-| `is_singular(other)` | `website` |
-| `is_front_page()` / `is_home()` | `website` |
-| `is_tax()` / `is_category()` / `is_tag()` | `website` |
-
----
-
-## Image Compatibility
-
-Social crawlers (Facebook, X, LinkedIn, WhatsApp, Telegram) generally require:
-- Format: **JPEG or PNG** (WebP partial support; AVIF not supported)
-- Minimum size: 200×200 px (Facebook requires 200×200 for `summary`)
-- Recommended: 1200×630 px for `summary_large_image`
-- Max file size: 8 MB (Facebook), 5 MB (Twitter)
-
-This plugin's core function is to detect when a featured image is in an incompatible format (AVIF, WebP) and automatically serve a JPEG/PNG alternative via fallback resolution.
-
----
-
-## Validation Tools
-
-- **Facebook**: [Sharing Debugger](https://developers.facebook.com/tools/debug/)
-- **Twitter/X**: [Card Validator](https://cards-dev.twitter.com/validator)
-- **LinkedIn**: [Post Inspector](https://www.linkedin.com/post-inspector/)
-- **OpenGraph.xyz**: [OpenGraph preview](https://www.opengraph.xyz/)
-- **Metatags.io**: [Meta tag preview](https://metatags.io/)
diff --git a/docs/WP-CLI.md b/docs/WP-CLI.md
deleted file mode 100644
index 800cff9..0000000
--- a/docs/WP-CLI.md
+++ /dev/null
@@ -1,103 +0,0 @@
-# WP-CLI Command Reference
-
-The plugin registers WP-CLI commands under the `og-fallback` namespace.
-
-## Commands
-
-### `wp og-fallback resolve`
-
-Resolves (or re-resolves) the OG fallback image for one or all posts with featured images.
-
-**Usage:**
-
-```bash
-wp og-fallback resolve [] [--all] [--dry-run] [--post-type=]
-```
-
-**Arguments:**
-
-| Argument | Description |
-|----------|-------------|
-| `` | (optional) Single post ID to resolve. |
-| `--all` | Process all posts that have a featured image. |
-| `--dry-run` | Preview without making any changes. |
-| `--post-type=` | Limit `--all` to a specific post type (default: `any`). |
-
-**Examples:**
-
-```bash
-# Resolve fallback for a single post
-wp og-fallback resolve 123
-
-# Preview for a single post without saving
-wp og-fallback resolve 123 --dry-run
-
-# Re-resolve all posts (clears cache first)
-wp og-fallback resolve --all
-
-# Re-resolve only 'product' post type posts
-wp og-fallback resolve --all --post-type=product
-
-# Dry-run all (shows count, no changes)
-wp og-fallback resolve --all --dry-run
-```
-
-**Output examples:**
-
-```
-Success: Post 123 resolved to https://example.com/uploads/image.jpg
-Warning: Post 456: no compatible image found.
-Success: Resolved 47 posts. Failed: 2.
-Found 49 posts with featured images. (dry-run, no changes made)
-```
-
----
-
-### `wp og-fallback clear-cache`
-
-Deletes cached fallback URLs from postmeta.
-
-**Usage:**
-
-```bash
-wp og-fallback clear-cache [] [--all] [--dry-run]
-```
-
-**Arguments:**
-
-| Argument | Description |
-|----------|-------------|
-| `` | (optional) Single post ID to clear cache for. |
-| `--all` | Clear all cached fallback URLs. |
-| `--dry-run` | Preview without making any changes. |
-
-**Examples:**
-
-```bash
-# Clear cache for a single post
-wp og-fallback clear-cache 123
-
-# Clear all cached fallback URLs
-wp og-fallback clear-cache --all
-
-# Preview how many entries would be cleared
-wp og-fallback clear-cache --all --dry-run
-```
-
-**Output examples:**
-
-```
-Success: Cleared cached fallback URL for post 123.
-Warning: Post 456 has no cached fallback URL.
-Success: Cleared 47 cached fallback URLs.
-Found 47 cached fallback URLs. (dry-run, no changes made)
-```
-
----
-
-## Notes
-
-- Both commands require WP-CLI 2.x.
-- No capability check is enforced at the CLI level (WP-CLI access implies server-level trust).
-- The `--all` flag with `resolve` is equivalent to clicking **Re-resolve All Images** in the admin Tools tab.
-- The `--all` flag with `clear-cache` is equivalent to clicking **Clear All Cached URLs** in the admin Tools tab.
diff --git a/includes/class-robotstxt-og-cli.php b/includes/class-robotstxt-og-cli.php
index f0be40c..17d2173 100644
--- a/includes/class-robotstxt-og-cli.php
+++ b/includes/class-robotstxt-og-cli.php
@@ -87,14 +87,14 @@ class Robotstxt_OG_CLI extends WP_CLI_Command {
*
* @since 1.0.0
*
- * @param array $args Positional arguments.
- * @param array $assoc_args Associative arguments.
+ * @param array $args Positional arguments.
+ * @param array $assoc_args Associative arguments.
* @return void
*/
public function resolve( array $args, array $assoc_args ): void {
$all = isset( $assoc_args['all'] );
$dry_run = isset( $assoc_args['dry-run'] );
- $post_type = isset( $assoc_args['post-type'] ) ? $assoc_args['post-type'] : 'any';
+ $post_type = ( isset( $assoc_args['post-type'] ) && is_string( $assoc_args['post-type'] ) ) ? $assoc_args['post-type'] : 'any';
if ( $all ) {
$this->resolve_all( $dry_run, $post_type );
@@ -132,8 +132,8 @@ class Robotstxt_OG_CLI extends WP_CLI_Command {
*
* @since 1.0.0
*
- * @param array $args Positional arguments.
- * @param array $assoc_args Associative arguments.
+ * @param array $args Positional arguments.
+ * @param array $assoc_args Associative arguments.
* @return void
*/
public function clear_cache( array $args, array $assoc_args ): void {
@@ -320,7 +320,7 @@ class Robotstxt_OG_CLI extends WP_CLI_Command {
if ( $dry_run ) {
/* translators: 1: post ID, 2: cached URL or 'none' */
- WP_CLI::line( sprintf( __( 'Post %1$d cached URL: %2$s (dry-run, no changes made)', 'robotstxt-og' ), $post_id, ! empty( $cached ) ? $cached : 'none' ) );
+ WP_CLI::line( sprintf( __( 'Post %1$d cached URL: %2$s (dry-run, no changes made)', 'robotstxt-og' ), $post_id, ( is_string( $cached ) && ! empty( $cached ) ) ? $cached : 'none' ) );
return;
}
diff --git a/includes/class-robotstxt-og-image-fallback.php b/includes/class-robotstxt-og-image-fallback.php
index de0425c..f9825ff 100644
--- a/includes/class-robotstxt-og-image-fallback.php
+++ b/includes/class-robotstxt-og-image-fallback.php
@@ -136,6 +136,10 @@ class Robotstxt_OG_Image_Fallback {
add_action( 'updated_post_meta', array( $this, 'handle_thumbnail_change' ), 10, 4 );
add_action( 'deleted_post_meta', array( $this, 'handle_thumbnail_change' ), 10, 4 );
+ // Register GDPR privacy data handlers.
+ add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'register_privacy_exporter' ) );
+ add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_privacy_eraser' ) );
+
// Register activation and deactivation hooks.
register_activation_hook( ROBOTSTXT_OG_PATH . 'robotstxt-og.php', array( $this, 'activate' ) );
register_deactivation_hook( ROBOTSTXT_OG_PATH . 'robotstxt-og.php', array( $this, 'deactivate' ) );
@@ -209,13 +213,13 @@ class Robotstxt_OG_Image_Fallback {
*
* @since 1.0.0
*
- * @param int $meta_id ID of the meta data entry.
- * @param int $post_id Post ID.
- * @param string $meta_key Meta key being updated.
- * @param mixed $meta_value New meta value (unused).
+ * @param int|int[] $meta_id ID or array of IDs of the meta data entry.
+ * @param int $post_id Post ID.
+ * @param string $meta_key Meta key being updated.
+ * @param mixed $meta_value New meta value (unused).
* @return void
*/
- public function handle_thumbnail_change( int $meta_id, int $post_id, string $meta_key, $meta_value = null ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+ public function handle_thumbnail_change( int|array $meta_id, int $post_id, string $meta_key, $meta_value = null ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
if ( '_thumbnail_id' !== $meta_key ) {
return;
}
@@ -223,6 +227,171 @@ class Robotstxt_OG_Image_Fallback {
$this->resolver->clear_cache( $post_id );
}
+ /**
+ * Register the personal data exporter.
+ *
+ * @since 1.0.3
+ *
+ * @param array> $exporters List of exporters.
+ * @return array> Updated exporters list.
+ */
+ public function register_privacy_exporter( array $exporters ): array {
+ $exporters[] = array(
+ 'exporter_friendly_name' => __( 'OpenGraph Custom Post Data', 'robotstxt-og' ),
+ 'callback' => array( $this, 'export_privacy_data' ),
+ );
+ return $exporters;
+ }
+
+ /**
+ * Register the personal data eraser.
+ *
+ * @since 1.0.3
+ *
+ * @param array> $erasers List of erasers.
+ * @return array> Updated erasers list.
+ */
+ public function register_privacy_eraser( array $erasers ): array {
+ $erasers[] = array(
+ 'eraser_friendly_name' => __( 'OpenGraph Custom Post Data', 'robotstxt-og' ),
+ 'callback' => array( $this, 'erase_privacy_data' ),
+ );
+ return $erasers;
+ }
+
+ /**
+ * Export personal data stored by this plugin for a given email address.
+ *
+ * Exports the custom og:title and og:description stored in post meta for
+ * all posts authored by the user with the given email address.
+ *
+ * @since 1.0.3
+ *
+ * @param string $email_address User email address.
+ * @param int $page Pagination page (1-based).
+ * @return array{data: array>, done: bool} Export result.
+ */
+ public function export_privacy_data( string $email_address, int $page = 1 ): array {
+ $user = get_user_by( 'email', $email_address );
+
+ if ( ! $user ) {
+ return array(
+ 'data' => array(),
+ 'done' => true,
+ );
+ }
+
+ $posts = get_posts(
+ array(
+ 'author' => $user->ID,
+ 'post_type' => 'any',
+ 'post_status' => 'any',
+ 'posts_per_page' => 100,
+ 'paged' => $page,
+ 'fields' => 'ids',
+ )
+ );
+
+ $data = array();
+
+ foreach ( $posts as $post_id ) {
+ $og_title = get_post_meta( $post_id, '_og_title', true );
+ $og_desc = get_post_meta( $post_id, '_og_description', true );
+
+ if ( empty( $og_title ) && empty( $og_desc ) ) {
+ continue;
+ }
+
+ $item_data = array();
+
+ if ( ! empty( $og_title ) && is_string( $og_title ) ) {
+ $item_data[] = array(
+ 'name' => __( 'Custom OG Title', 'robotstxt-og' ),
+ 'value' => $og_title,
+ );
+ }
+
+ if ( ! empty( $og_desc ) && is_string( $og_desc ) ) {
+ $item_data[] = array(
+ 'name' => __( 'Custom OG Description', 'robotstxt-og' ),
+ 'value' => $og_desc,
+ );
+ }
+
+ if ( ! empty( $item_data ) ) {
+ $data[] = array(
+ 'group_id' => 'robotstxt-og-post-meta',
+ 'group_label' => __( 'OpenGraph Post Meta', 'robotstxt-og' ),
+ 'item_id' => 'post-' . $post_id,
+ 'data' => $item_data,
+ );
+ }
+ }
+
+ $done = count( $posts ) < 100;
+
+ return array(
+ 'data' => $data,
+ 'done' => $done,
+ );
+ }
+
+ /**
+ * Erase personal data stored by this plugin for a given email address.
+ *
+ * Removes custom og:title and og:description from all posts authored by
+ * the user with the given email address.
+ *
+ * @since 1.0.3
+ *
+ * @param string $email_address User email address.
+ * @param int $page Pagination page (1-based).
+ * @return array{items_removed: int, items_retained: int, messages: string[], done: bool} Erase result.
+ */
+ public function erase_privacy_data( string $email_address, int $page = 1 ): array {
+ $user = get_user_by( 'email', $email_address );
+
+ if ( ! $user ) {
+ return array(
+ 'items_removed' => 0,
+ 'items_retained' => 0,
+ 'messages' => array(),
+ 'done' => true,
+ );
+ }
+
+ $posts = get_posts(
+ array(
+ 'author' => $user->ID,
+ 'post_type' => 'any',
+ 'post_status' => 'any',
+ 'posts_per_page' => 100,
+ 'paged' => $page,
+ 'fields' => 'ids',
+ )
+ );
+
+ $items_removed = 0;
+
+ foreach ( $posts as $post_id ) {
+ $deleted_title = delete_post_meta( $post_id, '_og_title' );
+ $deleted_desc = delete_post_meta( $post_id, '_og_description' );
+
+ if ( $deleted_title || $deleted_desc ) {
+ ++$items_removed;
+ }
+ }
+
+ $done = count( $posts ) < 100;
+
+ return array(
+ 'items_removed' => $items_removed,
+ 'items_retained' => 0,
+ 'messages' => array(),
+ 'done' => $done,
+ );
+ }
+
/**
* Get image resolver instance.
*
diff --git a/includes/class-robotstxt-og-image-resolver.php b/includes/class-robotstxt-og-image-resolver.php
index 1320482..c124e01 100644
--- a/includes/class-robotstxt-og-image-resolver.php
+++ b/includes/class-robotstxt-og-image-resolver.php
@@ -51,7 +51,7 @@ class Robotstxt_OG_Image_Resolver {
// Check postmeta cache first.
$cached_url = get_post_meta( $post_id, '_og_image_fallback_url', true );
- if ( ! empty( $cached_url ) && $this->is_valid_url( $cached_url ) ) {
+ if ( is_string( $cached_url ) && ! empty( $cached_url ) && $this->is_valid_url( $cached_url ) ) {
$this->log(
'cache_hit',
array(
@@ -103,7 +103,8 @@ class Robotstxt_OG_Image_Resolver {
}
// Detect file extension.
- $path_info = pathinfo( wp_parse_url( $image_url, PHP_URL_PATH ) );
+ $url_path = wp_parse_url( $image_url, PHP_URL_PATH );
+ $path_info = pathinfo( is_string( $url_path ) ? $url_path : '' );
$extension = isset( $path_info['extension'] ) ? strtolower( $path_info['extension'] ) : '';
// If already a compatible format, save and return.
@@ -191,9 +192,14 @@ class Robotstxt_OG_Image_Resolver {
// Strip query string for URL manipulation.
$clean_url = strtok( $image_url, '?' );
+ if ( ! is_string( $clean_url ) ) {
+ return '';
+ }
+
// Parse URL and get base path without extension.
- $path_info = pathinfo( wp_parse_url( $clean_url, PHP_URL_PATH ) );
- $filename = $path_info['filename'] ?? '';
+ $url_path = wp_parse_url( $clean_url, PHP_URL_PATH );
+ $path_info = pathinfo( is_string( $url_path ) ? $url_path : '' );
+ $filename = $path_info['filename'];
$dir = $path_info['dirname'] ?? '';
if ( empty( $filename ) ) {
@@ -224,10 +230,52 @@ class Robotstxt_OG_Image_Resolver {
return '';
}
+ /**
+ * Check whether a URL is safe to make server-side HTTP requests to.
+ *
+ * Rejects non-http(s) schemes and URLs that resolve to private or
+ * reserved IP ranges to prevent Server-Side Request Forgery (SSRF).
+ *
+ * @since 1.0.3
+ *
+ * @param string $url URL to validate.
+ * @return bool True if the URL is safe to request.
+ */
+ private function is_safe_url( string $url ): bool {
+ $scheme = wp_parse_url( $url, PHP_URL_SCHEME );
+ if ( ! in_array( $scheme, array( 'http', 'https' ), true ) ) {
+ return false;
+ }
+
+ $host = wp_parse_url( $url, PHP_URL_HOST );
+ if ( ! is_string( $host ) || '' === $host ) {
+ return false;
+ }
+
+ // Strip IPv6 brackets for validation.
+ $host_bare = trim( $host, '[]' );
+
+ // If the host is already an IP address, validate it directly.
+ if ( filter_var( $host_bare, FILTER_VALIDATE_IP ) !== false ) {
+ return (bool) filter_var( $host_bare, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE );
+ }
+
+ // Resolve hostname to IP and validate it is not private/reserved.
+ $resolved = gethostbyname( $host_bare );
+
+ // gethostbyname() returns the original string on failure.
+ if ( $resolved === $host_bare ) {
+ return false;
+ }
+
+ return (bool) filter_var( $resolved, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE );
+ }
+
/**
* Check if a URL exists using HTTP HEAD request.
*
* Uses negative caching to avoid repeated requests for non-existent URLs.
+ * URLs resolving to private or reserved IP ranges are rejected (SSRF protection).
*
* @since 1.0.0
*
@@ -235,6 +283,12 @@ class Robotstxt_OG_Image_Resolver {
* @return bool True if URL returns 200-299 status code.
*/
private function url_exists( string $url ): bool {
+ // Reject URLs that resolve to private/reserved IP ranges (SSRF protection).
+ if ( ! $this->is_safe_url( $url ) ) {
+ $this->log( 'ssrf_blocked', array( 'url' => $url ) );
+ return false;
+ }
+
// Check negative cache to avoid repeated failed requests.
$cache_key = self::NEGATIVE_CACHE_PREFIX . md5( $url );
@@ -333,7 +387,8 @@ class Robotstxt_OG_Image_Resolver {
* @return string Compatible image URL or empty string.
*/
private function get_global_fallback(): string {
- $fallback_url = (string) get_option( 'robotstxt_og_fallback_image', '' );
+ $option_value = get_option( 'robotstxt_og_fallback_image', '' );
+ $fallback_url = is_string( $option_value ) ? $option_value : '';
if ( empty( $fallback_url ) || ! $this->is_valid_url( $fallback_url ) ) {
return '';
@@ -354,7 +409,8 @@ class Robotstxt_OG_Image_Resolver {
* @return string Compatible image URL or empty string.
*/
public function get_homepage_image(): string {
- $image_url = (string) get_option( 'robotstxt_og_homepage_image', '' );
+ $option_value = get_option( 'robotstxt_og_homepage_image', '' );
+ $image_url = is_string( $option_value ) ? $option_value : '';
if ( ! empty( $image_url ) && $this->is_valid_url( $image_url ) ) {
return esc_url_raw( $image_url );
@@ -378,7 +434,8 @@ class Robotstxt_OG_Image_Resolver {
* @return string Compatible image URL or empty string.
*/
public function ensure_compatible_format( string $image_url ): string {
- $path_info = pathinfo( wp_parse_url( $image_url, PHP_URL_PATH ) );
+ $url_path = wp_parse_url( $image_url, PHP_URL_PATH );
+ $path_info = pathinfo( is_string( $url_path ) ? $url_path : '' );
$extension = isset( $path_info['extension'] ) ? strtolower( $path_info['extension'] ) : '';
// Already a compatible format.
@@ -459,7 +516,7 @@ class Robotstxt_OG_Image_Resolver {
// Check term meta cache first.
$cached_url = get_term_meta( $term_id, '_og_image_fallback_url', true );
- if ( ! empty( $cached_url ) && $this->is_valid_url( $cached_url ) ) {
+ if ( is_string( $cached_url ) && ! empty( $cached_url ) && $this->is_valid_url( $cached_url ) ) {
$this->log(
'taxonomy_cache_hit',
array(
@@ -504,7 +561,8 @@ class Robotstxt_OG_Image_Resolver {
}
// Detect file extension and resolve if needed.
- $path_info = pathinfo( wp_parse_url( $image_url, PHP_URL_PATH ) );
+ $url_path = wp_parse_url( $image_url, PHP_URL_PATH );
+ $path_info = pathinfo( is_string( $url_path ) ? $url_path : '' );
$extension = isset( $path_info['extension'] ) ? strtolower( $path_info['extension'] ) : '';
if ( in_array( $extension, array( 'jpg', 'jpeg', 'png' ), true ) ) {
@@ -555,8 +613,8 @@ class Robotstxt_OG_Image_Resolver {
*
* @since 1.0.0
*
- * @param string $event Event name (e.g., 'cache_hit', 'head_request_error').
- * @param array $context Additional context data.
+ * @param string $event Event name (e.g., 'cache_hit', 'head_request_error').
+ * @param array $context Additional context data.
* @return void
*/
private function log( string $event, array $context = array() ): void {
diff --git a/includes/class-robotstxt-og-meta-box.php b/includes/class-robotstxt-og-meta-box.php
index aeafa04..7f24ce1 100644
--- a/includes/class-robotstxt-og-meta-box.php
+++ b/includes/class-robotstxt-og-meta-box.php
@@ -118,8 +118,10 @@ class Robotstxt_OG_Meta_Box {
public function render_meta_box( WP_Post $post ): void {
wp_nonce_field( 'robotstxt_og_meta_box', 'robotstxt_og_meta_box_nonce' );
- $og_title = (string) get_post_meta( $post->ID, '_og_title', true );
- $og_description = (string) get_post_meta( $post->ID, '_og_description', true );
+ $raw_title = get_post_meta( $post->ID, '_og_title', true );
+ $og_title = is_string( $raw_title ) ? $raw_title : '';
+ $raw_desc = get_post_meta( $post->ID, '_og_description', true );
+ $og_description = is_string( $raw_desc ) ? $raw_desc : '';
?>