This commit is contained in:
Javier Casares 2026-03-28 07:59:52 +00:00
commit ddaaff71ad
23 changed files with 879 additions and 798 deletions

View file

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

132
README.md
View file

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

View file

@ -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 : '';
?>
<input
type="url"
@ -368,7 +387,8 @@ class Robotstxt_OG_Admin_Settings {
* @return void
*/
public function render_homepage_image_field(): void {
$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 : '';
?>
<input
type="url"
@ -492,7 +512,8 @@ class Robotstxt_OG_Admin_Settings {
* @return void
*/
public function render_twitter_site_field(): void {
$handle = (string) get_option( 'robotstxt_og_twitter_site', '' );
$option_value = get_option( 'robotstxt_og_twitter_site', '' );
$handle = is_string( $option_value ) ? $option_value : '';
?>
<input
type="text"
@ -517,7 +538,7 @@ class Robotstxt_OG_Admin_Settings {
*/
public function render_settings_page(): void {
// Check user capabilities.
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' ) );
}
@ -534,13 +555,13 @@ class Robotstxt_OG_Admin_Settings {
*/
public function handle_clear_cache(): void {
// Check if this is a cache clear request.
$clear_cache = filter_input( INPUT_GET, 'robotstxt_og_clear_cache', FILTER_SANITIZE_SPECIAL_CHARS );
$clear_cache = filter_input( INPUT_GET, 'robotstxt_og_clear_cache', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
if ( null === $clear_cache ) {
return;
}
// 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_cache' ) ) {
@ -548,7 +569,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' ) );
}
@ -581,13 +602,13 @@ class Robotstxt_OG_Admin_Settings {
*/
public function handle_resolve_all(): void {
// Check if this is a resolve all request.
$resolve_all = filter_input( INPUT_GET, 'robotstxt_og_resolve_all', FILTER_SANITIZE_SPECIAL_CHARS );
$resolve_all = filter_input( INPUT_GET, 'robotstxt_og_resolve_all', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
if ( null === $resolve_all ) {
return;
}
// 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_resolve_all' ) ) {
@ -595,7 +616,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' ) );
}

View file

@ -15,7 +15,8 @@ if ( ! defined( 'ABSPATH' ) ) {
// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$active_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'settings';
$tab_raw = isset( $_GET['tab'] ) && is_string( $_GET['tab'] ) ? $_GET['tab'] : '';
$active_tab = $tab_raw ? sanitize_key( wp_unslash( $tab_raw ) ) : 'settings';
$valid_tabs = array( 'settings', 'tools', 'diagnostics' );
if ( ! in_array( $active_tab, $valid_tabs, true ) ) {
@ -174,7 +175,8 @@ $page_url = admin_url( 'options-general.php?page=robotstxt-og-settings' );
$posts_per_page = 20;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$current_page = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1;
$paged_raw = isset( $_GET['paged'] ) && is_string( $_GET['paged'] ) ? $_GET['paged'] : '';
$current_page = $paged_raw ? max( 1, absint( $paged_raw ) ) : 1;
$offset = ( $current_page - 1 ) * $posts_per_page;
$total_pages = (int) ceil( $total_cached / $posts_per_page );
@ -237,7 +239,8 @@ $page_url = admin_url( 'options-general.php?page=robotstxt-og-settings' );
<td>
<?php
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$test_url_raw = isset( $_GET['test_url'] ) ? esc_url_raw( wp_unslash( $_GET['test_url'] ) ) : '';
$test_url_input = isset( $_GET['test_url'] ) && is_string( $_GET['test_url'] ) ? $_GET['test_url'] : '';
$test_url_raw = $test_url_input ? esc_url_raw( wp_unslash( $test_url_input ) ) : '';
?>
<input
type="url"
@ -289,7 +292,14 @@ $page_url = admin_url( 'options-general.php?page=robotstxt-og-settings' );
echo '<th>' . esc_html__( 'Value', 'robotstxt-og' ) . '</th></tr></thead><tbody>';
foreach ( $headers as $header_key => $header_value ) {
echo '<tr><td><code>' . esc_html( $header_key ) . '</code></td>';
echo '<td>' . esc_html( is_array( $header_value ) ? implode( ', ', $header_value ) : $header_value ) . '</td></tr>';
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 '<td>' . esc_html( $display_value ) . '</td></tr>';
}
echo '</tbody></table>';
}
@ -324,7 +334,7 @@ $page_url = admin_url( 'options-general.php?page=robotstxt-og-settings' );
<tr>
<td><?php echo absint( $cached_entry->post_id ); ?></td>
<td>
<a href="<?php echo esc_url( get_edit_post_link( $cached_entry->post_id ) ); ?>">
<a href="<?php echo esc_url( get_edit_post_link( $cached_entry->post_id ) ?? '' ); ?>">
<?php echo esc_html( $cached_entry->post_title ); ?>
</a>
</td>

View file

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

View file

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

View file

@ -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 (24 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
<!-- Core -->
<meta property="og:title" content="..." />
<meta property="og:type" content="article" /> <!-- or "website" -->
<meta property="og:url" content="..." />
<meta property="og:description" content="..." /> <!-- when available -->
<meta property="og:site_name" content="..." />
<meta property="og:locale" content="es_ES" />
<!-- Image (when a compatible image is resolved) -->
<meta property="og:image" content="https://...jpg" />
<meta property="og:image:secure_url" content="https://...jpg" /> <!-- HTTPS only -->
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:type" content="image/jpeg" />
<meta property="og:image:alt" content="..." /> <!-- when set in media library -->
<!-- Article-specific (og:type = article only) -->
<meta property="article:published_time" content="2026-02-18T00:00:00+00:00" />
<meta property="article:modified_time" content="2026-02-18T00:00:00+00:00" />
<meta property="article:section" content="Technology" /> <!-- primary category -->
<meta property="article:tag" content="WordPress" /> <!-- repeated per tag -->
```
#### Twitter Card tags
```html
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@example" /> <!-- when configured in Settings -->
<meta name="twitter:image" content="https://...jpg" /> <!-- when image available -->
```
> 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/)

View file

@ -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 [<post_id>] [--all] [--dry-run] [--post-type=<type>]
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `<post_id>` | (optional) Single post ID to resolve. |
| `--all` | Process all posts that have a featured image. |
| `--dry-run` | Preview without making any changes. |
| `--post-type=<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 [<post_id>] [--all] [--dry-run]
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `<post_id>` | (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.

View file

@ -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<int, string> $args Positional arguments.
* @param array<string, string|bool> $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<int, string> $args Positional arguments.
* @param array<string, string|bool> $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;
}

View file

@ -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<int, array<string, mixed>> $exporters List of exporters.
* @return array<int, array<string, mixed>> 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<int, array<string, mixed>> $erasers List of erasers.
* @return array<int, array<string, mixed>> 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<int, array<string, mixed>>, 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.
*

View file

@ -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<string, mixed> $context Additional context data.
* @return void
*/
private function log( string $event, array $context = array() ): void {

View file

@ -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 : '';
?>
<table class="form-table" role="presentation">
@ -182,7 +184,7 @@ class Robotstxt_OG_Meta_Box {
}
// Verify nonce.
$nonce_raw = filter_input( INPUT_POST, 'robotstxt_og_meta_box_nonce', FILTER_SANITIZE_SPECIAL_CHARS );
$nonce_raw = filter_input( INPUT_POST, 'robotstxt_og_meta_box_nonce', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
$nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : '';
if ( ! wp_verify_nonce( $nonce, 'robotstxt_og_meta_box' ) ) {
@ -197,7 +199,7 @@ class Robotstxt_OG_Meta_Box {
}
// Save og:title.
$og_title_raw = filter_input( INPUT_POST, 'robotstxt_og_title', FILTER_SANITIZE_SPECIAL_CHARS );
$og_title_raw = filter_input( INPUT_POST, 'robotstxt_og_title', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
$og_title = $og_title_raw ? sanitize_text_field( wp_unslash( $og_title_raw ) ) : '';
if ( empty( $og_title ) ) {

View file

@ -121,7 +121,8 @@ class Robotstxt_OG_REST_API {
* @return WP_REST_Response|WP_Error Response object.
*/
public function handle_resolve( WP_REST_Request $request ) {
$post_id = (int) $request->get_param( 'post_id' );
$param = $request->get_param( 'post_id' );
$post_id = is_numeric( $param ) ? (int) $param : 0;
// Verify post exists.
$post = get_post( $post_id );
@ -159,7 +160,8 @@ class Robotstxt_OG_REST_API {
* @return WP_REST_Response|WP_Error Response object.
*/
public function handle_status( WP_REST_Request $request ) {
$post_id = (int) $request->get_param( 'post_id' );
$param = $request->get_param( 'post_id' );
$post_id = is_numeric( $param ) ? (int) $param : 0;
// Verify post exists.
$post = get_post( $post_id );
@ -189,14 +191,15 @@ class Robotstxt_OG_REST_API {
/**
* Check REST API permission.
*
* Requires manage_options capability.
* Requires edit_others_posts capability (editors and above).
*
* @since 1.0.0
*
* @param WP_REST_Request $request REST request object.
* @return bool|WP_Error True if authorized, WP_Error otherwise.
*/
public function check_permission() {
if ( ! current_user_can( 'manage_options' ) ) {
public function check_permission( WP_REST_Request $request ) {
if ( ! current_user_can( 'edit_others_posts' ) ) {
return new WP_Error(
'rest_forbidden',
__( 'You do not have permission to access this endpoint.', 'robotstxt-og' ),
@ -204,6 +207,18 @@ class Robotstxt_OG_REST_API {
);
}
// For post-specific endpoints, also verify the user can edit that post.
$param = $request->get_param( 'post_id' );
$post_id = is_numeric( $param ) ? (int) $param : 0;
if ( $post_id > 0 && ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error(
'rest_forbidden',
__( 'You do not have permission to edit this post.', 'robotstxt-og' ),
array( 'status' => 403 )
);
}
return true;
}
}

View file

@ -225,7 +225,8 @@ class Robotstxt_OG_Tags {
private function get_og_title(): string {
if ( is_singular() ) {
$post_id = get_queried_object_id();
$custom_title = (string) get_post_meta( $post_id, '_og_title', true );
$raw_title = get_post_meta( $post_id, '_og_title', true );
$custom_title = is_string( $raw_title ) ? $raw_title : '';
if ( ! empty( $custom_title ) ) {
return $custom_title;
@ -262,13 +263,15 @@ class Robotstxt_OG_Tags {
private function get_og_description(): string {
if ( is_singular() ) {
$post_id = get_queried_object_id();
$custom_desc = (string) get_post_meta( $post_id, '_og_description', true );
$raw_desc = get_post_meta( $post_id, '_og_description', true );
$custom_desc = is_string( $raw_desc ) ? $raw_desc : '';
if ( ! empty( $custom_desc ) ) {
return $custom_desc;
}
$excerpt = (string) get_post_field( 'post_excerpt', $post_id );
$raw_excerpt = get_post_field( 'post_excerpt', $post_id );
$excerpt = is_string( $raw_excerpt ) ? $raw_excerpt : '';
if ( ! empty( $excerpt ) ) {
return wp_strip_all_tags( $excerpt );
@ -455,7 +458,8 @@ class Robotstxt_OG_Tags {
}
// Alt text from the media library (set when uploading/editing the image).
$alt_text = (string) get_post_meta( $image_id, '_wp_attachment_image_alt', true );
$raw_alt = get_post_meta( $image_id, '_wp_attachment_image_alt', true );
$alt_text = is_string( $raw_alt ) ? $raw_alt : '';
if ( ! empty( $alt_text ) ) {
printf(
@ -489,7 +493,7 @@ class Robotstxt_OG_Tags {
$published = get_the_date( 'c', $post );
if ( ! empty( $published ) ) {
if ( is_string( $published ) && ! empty( $published ) ) {
printf(
'<meta property="article:published_time" content="%s" />' . "\n",
esc_attr( $published )
@ -498,7 +502,7 @@ class Robotstxt_OG_Tags {
$modified = get_the_modified_date( 'c', $post );
if ( ! empty( $modified ) ) {
if ( is_string( $modified ) && ! empty( $modified ) ) {
printf(
'<meta property="article:modified_time" content="%s" />' . "\n",
esc_attr( $modified )
@ -541,7 +545,8 @@ class Robotstxt_OG_Tags {
* @return void
*/
private function output_twitter_card_tags( string $image_url ): void {
$card_type = (string) get_option( 'robotstxt_og_twitter_card_type', 'summary_large_image' );
$card_option = get_option( 'robotstxt_og_twitter_card_type', 'summary_large_image' );
$card_type = is_string( $card_option ) ? $card_option : 'summary_large_image';
if ( ! in_array( $card_type, array( 'summary', 'summary_large_image' ), true ) ) {
$card_type = 'summary_large_image';
@ -554,7 +559,8 @@ class Robotstxt_OG_Tags {
);
// Site handle (e.g. @example).
$twitter_site = sanitize_text_field( (string) get_option( 'robotstxt_og_twitter_site', '' ) );
$twitter_option = get_option( 'robotstxt_og_twitter_site', '' );
$twitter_site = sanitize_text_field( is_string( $twitter_option ) ? $twitter_option : '' );
if ( ! empty( $twitter_site ) ) {
// Ensure the handle includes the @ prefix.

Binary file not shown.

View file

@ -0,0 +1,447 @@
# Translation of OpenGraph (by ROBOTSTXT) in Catalan
# This file is distributed under the same license as the OpenGraph (by ROBOTSTXT) package.
# Copyright (C) 2026 ROBOTSTXT
#
msgid ""
msgstr ""
"Project-Id-Version: OpenGraph (by ROBOTSTXT) 1.1.0\n"
"Report-Msgid-Bugs-To: https://git.robotstxt.es/ROBOTSTXT/robotstxt-og/issues\n"
"POT-Creation-Date: 2026-03-28\n"
"PO-Revision-Date: 2026-03-28 00:00+0000\n"
"Last-Translator: ROBOTSTXT <hola@robotstxt.es>\n"
"Language-Team: Catalan\n"
"Language: ca\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Manual\n"
"X-Domain: robotstxt-og\n"
#. Plugin Name of the plugin
msgid "OpenGraph (by ROBOTSTXT)"
msgstr "OpenGraph (by ROBOTSTXT)"
#. Plugin URI of the plugin
msgid "https://git.robotstxt.es/ROBOTSTXT/robotstxt-og"
msgstr "https://git.robotstxt.es/ROBOTSTXT/robotstxt-og"
#. Description of the plugin
msgid "Intelligent Open Graph image fallback for social media crawlers. Automatically detects and serves compatible image formats (JPEG/PNG) when modern formats (AVIF/WebP) are used as featured images."
msgstr "Imatge de reserva intel·ligent per a Open Graph en rastrejadors de xarxes socials. Detecta i serveix automàticament formats d'imatge compatibles (JPEG/PNG) quan s'utilitzen formats moderns (AVIF/WebP) com a imatges destacades."
#: admin/class-robotstxt-og-admin-settings.php
msgid "OpenGraph Settings"
msgstr "Configuració d'OpenGraph"
#: admin/class-robotstxt-og-admin-settings.php
msgid "OpenGraph"
msgstr "OpenGraph"
#: admin/class-robotstxt-og-admin-settings.php
msgid "You do not have sufficient permissions to access this page."
msgstr "No tens permisos suficients per accedir a aquesta pàgina."
#: admin/class-robotstxt-og-admin-settings.php
msgid "Security check failed"
msgstr "Ha fallat la verificació de seguretat"
#: admin/class-robotstxt-og-admin-settings.php
msgid "General Settings"
msgstr "Configuració general"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Configure OpenGraph image fallback behavior."
msgstr "Configura el comportament de la imatge de reserva per a OpenGraph."
#: admin/class-robotstxt-og-admin-settings.php
msgid "Global Fallback Image"
msgstr "Imatge de reserva global"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Homepage Image"
msgstr "Imatge de la pàgina principal"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Data Management"
msgstr "Gestió de dades"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Social Media Tags"
msgstr "Etiquetes per a xarxes socials"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Choose which social media meta tag groups to output and configure their behavior."
msgstr "Tria quins grups de metaetiquetes per a xarxes socials vols generar i configura'n el comportament."
#: admin/class-robotstxt-og-admin-settings.php
msgid "Facebook / OG Tags"
msgstr "Etiquetes Facebook / OG"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Twitter Card Tags"
msgstr "Etiquetes Twitter Card"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Twitter Card Type"
msgstr "Tipus de Twitter Card"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Select Image"
msgstr "Selecciona la imatge"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Remove Image"
msgstr "Elimina la imatge"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Used as Open Graph image when a post has no featured image or when no compatible format (JPEG/PNG) is found."
msgstr "S'utilitza com a imatge d'Open Graph quan una entrada no té imatge destacada o no es troba cap format compatible (JPEG/PNG)."
#: admin/class-robotstxt-og-admin-settings.php
msgid "Dedicated Open Graph image for the site homepage. Falls back to the Global Fallback Image if not set."
msgstr "Imatge d'Open Graph dedicada a la pàgina principal del lloc. Si no es configura, s'utilitza la imatge de reserva global."
#: admin/class-robotstxt-og-admin-settings.php
msgid "Delete all plugin data when uninstalling"
msgstr "Elimina totes les dades del connector en desinstal·lar"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Warning: This will permanently delete all cached fallback URLs and plugin settings. This action cannot be undone."
msgstr "Avís: Això eliminarà permanentment totes les URL de reserva en memòria cau i la configuració del connector. Aquesta acció no es pot desfer."
#: admin/class-robotstxt-og-admin-settings.php
msgid "Enable Facebook / Open Graph image tags"
msgstr "Activa les etiquetes d'imatge per a Facebook / Open Graph"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Outputs og:title, og:type, og:url, og:description, og:site_name, og:locale, og:image, og:image:width, og:image:height, og:image:type, and og:image:alt meta tags."
msgstr "Genera les metaetiquetes og:title, og:type, og:url, og:description, og:site_name, og:locale, og:image, og:image:width, og:image:height, og:image:type i og:image:alt."
#: admin/class-robotstxt-og-admin-settings.php
msgid "Enable Twitter Card image tags"
msgstr "Activa les etiquetes d'imatge per a Twitter Card"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Outputs twitter:card, twitter:site, and twitter:image meta tags."
msgstr "Genera les metaetiquetes twitter:card, twitter:site i twitter:image."
#: admin/class-robotstxt-og-admin-settings.php
msgid "Summary with large image"
msgstr "Resum amb imatge gran"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Summary (small image)"
msgstr "Resum (imatge petita)"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Controls the twitter:card meta tag value. \"Summary with large image\" is recommended for most sites."
msgstr "Controla el valor de la metaetiqueta twitter:card. Es recomana «Resum amb imatge gran» per a la majoria de llocs."
#: admin/class-robotstxt-og-admin-settings.php
msgid "Twitter/X Site Handle"
msgstr "Compte Twitter/X del lloc"
#: admin/class-robotstxt-og-admin-settings.php
msgid "Your site's Twitter/X account handle (e.g. @example). Used for the twitter:site meta tag."
msgstr "El compte Twitter/X del teu lloc (p. ex. @exemple). S'utilitza per a la metaetiqueta twitter:site."
#: admin/class-robotstxt-og-admin-settings.php
#. translators: %d: number of cleared cache entries
msgid "Successfully cleared %d cached fallback URLs."
msgstr "S'han eliminat correctament %d URL de reserva en memòria cau."
#: admin/class-robotstxt-og-admin-settings.php
#. translators: 1: number of successful resolutions 2: number of failed resolutions
msgid "Batch resolution complete. Success: %1$d, Failed: %2$d"
msgstr "Resolució per lots completada. Correctes: %1$d, Fallides: %2$d"
#: admin/class-robotstxt-og-admin-settings.php
#. translators: %d: post ID
msgid "Successfully cleared cached fallback URL for post #%d."
msgstr "S'ha eliminat correctament la URL de reserva en memòria cau per a l'entrada #%d."
#: admin/views/settings-page.php
msgid "Settings tabs"
msgstr "Pestanyes de configuració"
#: admin/views/settings-page.php
msgid "Settings"
msgstr "Configuració"
#: admin/views/settings-page.php
msgid "Tools"
msgstr "Eines"
#: admin/views/settings-page.php
msgid "Diagnostics"
msgstr "Diagnòstics"
#: admin/views/settings-page.php
msgid "Save Settings"
msgstr "Desa la configuració"
#: admin/views/settings-page.php
msgid "Cache Management"
msgstr "Gestió de la memòria cau"
#: admin/views/settings-page.php
msgid "Use these tools to manage cached fallback image URLs."
msgstr "Utilitza aquestes eines per gestionar les URL d'imatge de reserva emmagatzemades en memòria cau."
#: admin/views/settings-page.php
msgid "Clear All Caches"
msgstr "Buida tota la memòria cau"
#: admin/views/settings-page.php
msgid "Clear All Cached URLs"
msgstr "Elimina totes les URL en memòria cau"
#: admin/views/settings-page.php
msgid "Delete all cached fallback URLs. Images will be re-resolved on next page view."
msgstr "Elimina totes les URL de reserva en memòria cau. Les imatges es resoldran de nou en la propera visita a la pàgina."
#: admin/views/settings-page.php
msgid "Re-resolve All Images"
msgstr "Torna a resoldre totes les imatges"
#: admin/views/settings-page.php
msgid "Re-resolve All Images Now"
msgstr "Torna a resoldre totes les imatges ara"
#: admin/views/settings-page.php
msgid "Clear cache and immediately re-resolve all posts with featured images. May take time on large sites."
msgstr "Buida la memòria cau i torna a resoldre immediatament totes les entrades amb imatge destacada. Pot trigar en llocs grans."
#: admin/views/settings-page.php
msgid "Plugin Information"
msgstr "Informació del connector"
#: admin/views/settings-page.php
msgid "Version"
msgstr "Versió"
#: admin/views/settings-page.php
msgid "Documentation"
msgstr "Documentació"
#: admin/views/settings-page.php
msgid "View Documentation"
msgstr "Visualitza la documentació"
#: admin/views/settings-page.php
msgid "Support"
msgstr "Suport"
#: admin/views/settings-page.php
msgid "Report an Issue"
msgstr "Informa d'un problema"
#: admin/views/settings-page.php
msgid "Statistics"
msgstr "Estadístiques"
#: admin/views/settings-page.php
msgid "Posts with featured images"
msgstr "Entrades amb imatge destacada"
#: admin/views/settings-page.php
msgid "Posts with cached fallback URLs"
msgstr "Entrades amb URL de reserva en memòria cau"
#: admin/views/settings-page.php
msgid "Coverage"
msgstr "Cobertura"
#: admin/views/settings-page.php
msgid "N/A"
msgstr "N/D"
#: admin/views/settings-page.php
msgid "Test URL"
msgstr "Prova la URL"
#: admin/views/settings-page.php
msgid "Test whether a URL is reachable via HTTP HEAD request."
msgstr "Comprova si una URL és accessible mitjançant una petició HTTP HEAD."
#: admin/views/settings-page.php
msgid "URL to test"
msgstr "URL a provar"
#: admin/views/settings-page.php
msgid "Test Result"
msgstr "Resultat de la prova"
#: admin/views/settings-page.php
msgid "Error:"
msgstr "Error:"
#: admin/views/settings-page.php
msgid "HTTP Status:"
msgstr "Estat HTTP:"
#: admin/views/settings-page.php
msgid "Header"
msgstr "Capçalera"
#: admin/views/settings-page.php
msgid "Value"
msgstr "Valor"
#: admin/views/settings-page.php
#. translators: %d: total entries
msgid "Cached Fallback URLs (%d)"
msgstr "URL de reserva en memòria cau (%d)"
#: admin/views/settings-page.php
msgid "No cached fallback URLs found."
msgstr "No s'han trobat URL de reserva en memòria cau."
#: admin/views/settings-page.php
msgid "Post ID"
msgstr "ID d'entrada"
#: admin/views/settings-page.php
msgid "Title"
msgstr "Títol"
#: admin/views/settings-page.php
msgid "Type"
msgstr "Tipus"
#: admin/views/settings-page.php
msgid "Cached Fallback URL"
msgstr "URL de reserva en memòria cau"
#: admin/views/settings-page.php
msgid "Actions"
msgstr "Accions"
#: admin/views/settings-page.php
msgid "Clear"
msgstr "Neteja"
#: includes/class-robotstxt-og-cli.php
#. translators: %d: number of posts found
msgid "Found %d posts with featured images. (dry-run, no changes made)"
msgstr "S'han trobat %d entrades amb imatge destacada. (simulació, no s'han fet canvis)"
#: includes/class-robotstxt-og-cli.php
msgid "No posts with featured images found."
msgstr "No s'han trobat entrades amb imatge destacada."
#: includes/class-robotstxt-og-cli.php
#. translators: %d: number of posts to process
msgid "Resolving %d posts"
msgstr "S'estan resolent %d entrades"
#: includes/class-robotstxt-og-cli.php
#. translators: 1: successful count, 2: failed count
msgid "Resolved %1$d posts. Failed: %2$d."
msgstr "S'han resolt %1$d entrades. Fallides: %2$d."
#: includes/class-robotstxt-og-cli.php
#. translators: %d: post ID
msgid "Post %d not found."
msgstr "L'entrada %d no s'ha trobat."
#: includes/class-robotstxt-og-cli.php
#. translators: 1: post ID, 2: image URL
msgid "Post %1$d has featured image: %2$s (dry-run, no changes made)"
msgstr "L'entrada %1$d té imatge destacada: %2$s (simulació, no s'han fet canvis)"
#: includes/class-robotstxt-og-cli.php
#. translators: 1: post ID, 2: resolved URL
msgid "Post %1$d resolved to %2$s"
msgstr "L'entrada %1$d s'ha resolt a %2$s"
#: includes/class-robotstxt-og-cli.php
#. translators: %d: post ID
msgid "Post %d: no compatible image found."
msgstr "Entrada %d: no s'ha trobat cap imatge compatible."
#: includes/class-robotstxt-og-cli.php
#. translators: %d: number of cache entries found
msgid "Found %d cached fallback URLs. (dry-run, no changes made)"
msgstr "S'han trobat %d URL de reserva en memòria cau. (simulació, no s'han fet canvis)"
#: includes/class-robotstxt-og-cli.php
#. translators: %d: number of cleared entries
msgid "Cleared %d cached fallback URLs."
msgstr "S'han eliminat %d URL de reserva en memòria cau."
#: includes/class-robotstxt-og-cli.php
#. translators: 1: post ID, 2: cached URL or 'none'
msgid "Post %1$d cached URL: %2$s (dry-run, no changes made)"
msgstr "URL en memòria cau de l'entrada %1$d: %2$s (simulació, no s'han fet canvis)"
#: includes/class-robotstxt-og-cli.php
#. translators: %d: post ID
msgid "Post %d has no cached fallback URL."
msgstr "L'entrada %d no té URL de reserva en memòria cau."
#: includes/class-robotstxt-og-cli.php
#. translators: %d: post ID
msgid "Cleared cached fallback URL for post %d."
msgstr "S'ha eliminat la URL de reserva en memòria cau per a l'entrada %d."
#: includes/class-robotstxt-og-cli.php
msgid "Please specify a post ID or use --all flag."
msgstr "Especifica un ID d'entrada o utilitza l'opció --all."
#: includes/class-robotstxt-og-rest-api.php
msgid "Post not found."
msgstr "L'entrada no s'ha trobat."
#: includes/class-robotstxt-og-rest-api.php
msgid "You do not have permission to access this endpoint."
msgstr "No tens permís per accedir a aquest punt d'accés."
#: includes/class-robotstxt-og-rest-api.php
msgid "You do not have permission to edit this post."
msgstr "No tens permís per editar aquesta entrada."
#: includes/class-robotstxt-og-rest-api.php
msgid "The post ID to resolve the fallback image for."
msgstr "L'ID de l'entrada per a la qual resoldre la imatge de reserva."
#: includes/class-robotstxt-og-rest-api.php
msgid "The post ID to get the fallback image status for."
msgstr "L'ID de l'entrada de la qual obtenir l'estat de la imatge de reserva."
#: includes/class-robotstxt-og-meta-box.php
msgid "Open Graph / Social Media"
msgstr "Open Graph / Xarxes Socials"
#: includes/class-robotstxt-og-meta-box.php
msgid "Custom Title"
msgstr "Títol personalitzat"
#: includes/class-robotstxt-og-meta-box.php
msgid "Overrides the default og:title for this post. Leave blank to use the post title automatically."
msgstr "Substitueix l'og:title predeterminat per a aquesta entrada. Deixa-ho en blanc per utilitzar el títol de l'entrada automàticament."
#: includes/class-robotstxt-og-meta-box.php
msgid "Custom Description"
msgstr "Descripció personalitzada"
#: includes/class-robotstxt-og-meta-box.php
msgid "Overrides the default og:description for this post. Leave blank to use the excerpt automatically."
msgstr "Substitueix l'og:description predeterminat per a aquesta entrada. Deixa-ho en blanc per utilitzar l'extracte automàticament."
#: includes/class-robotstxt-og-image-fallback.php
msgid "OpenGraph Custom Post Data"
msgstr "Dades personalitzades d'Open Graph"
#: includes/class-robotstxt-og-image-fallback.php
msgid "OpenGraph Post Meta"
msgstr "Metadades Open Graph de l'entrada"
#: includes/class-robotstxt-og-image-fallback.php
msgid "Custom OG Title"
msgstr "Títol OG personalitzat"
#: includes/class-robotstxt-og-image-fallback.php
msgid "Custom OG Description"
msgstr "Descripció OG personalitzada"

Binary file not shown.

View file

@ -6,8 +6,8 @@ msgid ""
msgstr ""
"Project-Id-Version: OpenGraph (by ROBOTSTXT) 1.1.0\n"
"Report-Msgid-Bugs-To: https://git.robotstxt.es/ROBOTSTXT/robotstxt-og/issues\n"
"POT-Creation-Date: 2026-02-18\n"
"PO-Revision-Date: 2026-02-18 00:00+0000\n"
"POT-Creation-Date: 2026-03-28\n"
"PO-Revision-Date: 2026-03-28 00:00+0000\n"
"Last-Translator: ROBOTSTXT <hola@robotstxt.es>\n"
"Language-Team: Spanish (Spain)\n"
"Language: es_ES\n"
@ -398,6 +398,26 @@ msgstr "Entrada no encontrada."
msgid "You do not have permission to access this endpoint."
msgstr "No tienes permiso para acceder a este endpoint."
#: includes/class-robotstxt-og-rest-api.php
msgid "You do not have permission to edit this post."
msgstr "No tienes permiso para editar esta entrada."
#: includes/class-robotstxt-og-image-fallback.php
msgid "OpenGraph Custom Post Data"
msgstr "Datos personalizados de Open Graph"
#: includes/class-robotstxt-og-image-fallback.php
msgid "OpenGraph Post Meta"
msgstr "Metadatos Open Graph de la entrada"
#: includes/class-robotstxt-og-image-fallback.php
msgid "Custom OG Title"
msgstr "Título OG personalizado"
#: includes/class-robotstxt-og-image-fallback.php
msgid "Custom OG Description"
msgstr "Descripción OG personalizada"
#: includes/class-robotstxt-og-rest-api.php
msgid "The post ID to resolve the fallback image for."
msgstr "El ID de entrada para el que resolver la imagen de respaldo."

View file

@ -1,11 +1,11 @@
=== OpenGraph (by ROBOTSTXT) ===
Contributors: javiercasares, robotstxt
Tags: opengraph, open graph, twitter card, social media, seo
Requires at least: 6.7
Requires at least: 6.8
Tested up to: 7.0
Stable tag: 1.0.1
Stable tag: 1.1.0
Requires PHP: 8.2
Version: 1.0.1
Version: 1.1.0
License: GPL-3.0-or-later
License URI: https://www.gnu.org/licenses/gpl-3.0.txt
@ -42,7 +42,7 @@ Social media crawlers — Facebook, Twitter/X, LinkedIn, WhatsApp — do not sup
* WP-CLI commands for batch operations
* REST API endpoints for programmatic access
* Multisite compatible (per-site configuration)
* Full internationalization support (es_ES included)
* Full internationalization support (es_ES and ca included)
== Using the plugin ==
@ -78,7 +78,7 @@ Clear all cached fallback URLs:
= REST API =
The plugin exposes two REST API endpoints under the `robotstxt-og/v1` namespace. Both require the `manage_options` capability (administrator authentication).
The plugin exposes two REST API endpoints under the `robotstxt-og/v1` namespace. Both require the `edit_others_posts` capability (editor and above).
**Force re-resolve a post's fallback image:**
@ -163,7 +163,7 @@ Yes. The plugin hooks into `wp_head` for direct tag injection and filters Yoast/
== Compatibility ==
* WordPress: 6.7 - 7.0
* WordPress: 6.8 - 7.0
* PHP: 8.2 - 8.5
* WP-CLI: 2.x
* MariaDB: 10.6+
@ -175,11 +175,18 @@ Yes. The plugin hooks into `wp_head` for direct tag injection and filters Yoast/
== Changelog ==
= 1.0.1 =
= 1.1.0 =
_Release date: 2026-03-05_
_Release date: 2026-03-28_
* Confirmed compatibility with WordPress 7.0.
* Fixed: Fatal `TypeError` in `handle_thumbnail_change()` when `deleted_post_meta` passes an array of meta IDs as the first argument.
* Security: Added SSRF protection — outbound HEAD requests now block private and reserved IP ranges.
* Security: Replaced deprecated `FILTER_SANITIZE_SPECIAL_CHARS` with `FILTER_SANITIZE_FULL_SPECIAL_CHARS`.
* Added: GDPR Privacy API — custom OG title and description are included in WordPress personal data export and erase.
* Changed: Settings page and REST API now require `edit_others_posts`, allowing editors to manage OG settings.
* Changed: Minimum supported WordPress version raised to 6.8.
* Changed: Added `Network: true` header confirming Multisite compatibility.
* Added: PHPUnit test suite (Brain\Monkey) covering SSRF protection, format detection, and cache clearing.
= 1.0.0 =

View file

@ -3,8 +3,8 @@
* Plugin Name: OpenGraph (by ROBOTSTXT)
* Plugin URI: https://git.robotstxt.es/ROBOTSTXT/robotstxt-og
* Description: Intelligent Open Graph image fallback for social media crawlers. Automatically detects and serves compatible image formats (JPEG/PNG) when modern formats (AVIF/WebP) are used as featured images.
* Version: 1.0.1
* Requires at least: 6.7
* Version: 1.1.0
* Requires at least: 6.8
* Requires PHP: 8.2
* Author: ROBOTSTXT
* Author URI: https://www.robotstxt.es/
@ -12,11 +12,12 @@
* License URI: https://www.gnu.org/licenses/gpl-3.0.html
* Text Domain: robotstxt-og
* Domain Path: /languages
* Network: true
* Gitea Plugin URI: ROBOTSTXT/robotstxt-og
* Contributors: javiercasares, robotstxt
*
* @package ROBOTSTXT_OG
* @version 1.0.1
* @version 1.1.0
*/
if ( ! defined( 'ABSPATH' ) ) {
@ -24,7 +25,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
// Define plugin constants.
define( 'ROBOTSTXT_OG_VERSION', '1.0.1' );
define( 'ROBOTSTXT_OG_VERSION', '1.1.0' );
define( 'ROBOTSTXT_OG_PATH', plugin_dir_path( __FILE__ ) );
define( 'ROBOTSTXT_OG_URL', plugin_dir_url( __FILE__ ) );
define( 'ROBOTSTXT_OG_BASENAME', plugin_basename( __FILE__ ) );

View file

@ -60,7 +60,7 @@ if ( ! class_exists( 'Robotstxt_Updater' ) ) {
/**
* Plugin headers.
*
* @var array
* @var array<string, mixed>
*/
private array $plugin_data;
@ -105,7 +105,7 @@ if ( ! class_exists( 'Robotstxt_Updater' ) ) {
/**
* Get plugin headers.
*
* @return array Plugin data.
* @return array<string, mixed> Plugin data.
*/
private function get_plugin_data(): array {
if ( ! function_exists( 'get_plugin_data' ) ) {
@ -125,7 +125,7 @@ if ( ! class_exists( 'Robotstxt_Updater' ) ) {
*/
private function build_json_url(): string {
// Try Gitea Plugin URI (format: "OWNER/REPO" or full URL).
if ( ! empty( $this->plugin_data['Gitea Plugin URI'] ) ) {
if ( ! empty( $this->plugin_data['Gitea Plugin URI'] ) && is_string( $this->plugin_data['Gitea Plugin URI'] ) ) {
$gitea_uri = $this->plugin_data['Gitea Plugin URI'];
// If it's already a full URL, use it.
@ -141,7 +141,7 @@ if ( ! class_exists( 'Robotstxt_Updater' ) ) {
}
// Fallback: try to extract from Plugin URI.
if ( ! empty( $this->plugin_data['PluginURI'] ) ) {
if ( ! empty( $this->plugin_data['PluginURI'] ) && is_string( $this->plugin_data['PluginURI'] ) ) {
$plugin_uri = $this->plugin_data['PluginURI'];
if ( str_contains( $plugin_uri, 'git.robotstxt.es' ) ) {
return rtrim( $plugin_uri, '/' ) . '/raw/branch/main/update.json';
@ -160,7 +160,7 @@ if ( ! class_exists( 'Robotstxt_Updater' ) ) {
* @return object The modified transient.
*/
public function inject_update_info( $transient ) {
if ( ! is_object( $transient ) ) {
if ( ! ( $transient instanceof stdClass ) ) {
$transient = new stdClass();
}
@ -183,7 +183,7 @@ if ( ! class_exists( 'Robotstxt_Updater' ) ) {
return $transient;
}
if ( version_compare( $remote['version'], $current_version, '>' ) ) {
if ( is_string( $current_version ) && is_string( $remote['version'] ) && version_compare( $remote['version'], $current_version, '>' ) ) {
$update = (object) array(
'slug' => $remote['slug'] ?? $this->plugin_slug,
'plugin' => $this->plugin_basename,
@ -204,11 +204,11 @@ if ( ! class_exists( 'Robotstxt_Updater' ) ) {
/**
* Provide "View details" modal content.
*
* @param false|object|array $result The result object or array.
* @param string $action The type of information being requested.
* @param object $args Plugin API arguments.
* @param false|object|array<string, mixed> $result The result object or array.
* @param string $action The type of information being requested.
* @param object $args Plugin API arguments.
*
* @return false|object The plugin information object or false.
* @return false|object|array<string, mixed> The plugin information object or false.
*/
public function provide_plugin_details( $result, string $action, object $args ) {
if ( 'plugin_information' !== $action ) {
@ -245,7 +245,7 @@ if ( ! class_exists( 'Robotstxt_Updater' ) ) {
/**
* Get remote data with caching and HMAC signature verification.
*
* @return array Remote data.
* @return array<string, mixed> Remote data.
*/
private function get_remote_data(): array {
$cached = get_site_transient( $this->cache_key );
@ -255,7 +255,7 @@ if ( ! class_exists( 'Robotstxt_Updater' ) ) {
if ( is_array( $cached ) && isset( $cached['signature'], $cached['data'] ) ) {
$expected_sig = hash_hmac( 'sha256', $this->cache_key . serialize( $cached['data'] ), AUTH_SALT );
if ( hash_equals( $expected_sig, $cached['signature'] ) ) {
if ( is_string( $cached['signature'] ) && hash_equals( $expected_sig, $cached['signature'] ) ) {
// Signature valid, return data.
return is_array( $cached['data'] ) ? $cached['data'] : array();
}
@ -283,7 +283,7 @@ if ( ! class_exists( 'Robotstxt_Updater' ) ) {
set_site_transient( $this->cache_key, $remote ?: array(), 6 * HOUR_IN_SECONDS );
}
return is_array( $remote ) ? $remote : array();
return $remote;
}
// Legacy cache format without signature (backward compatibility).
@ -293,7 +293,7 @@ if ( ! class_exists( 'Robotstxt_Updater' ) ) {
/**
* Fetch JSON from remote URL.
*
* @return array Decoded JSON data.
* @return array<string, mixed> Decoded JSON data.
*/
private function fetch_json(): array {
$response = wp_remote_get(
@ -324,18 +324,18 @@ if ( ! class_exists( 'Robotstxt_Updater' ) ) {
/**
* Check compatibility.
*
* @param array $remote Remote data.
* @param array<string, mixed> $remote Remote data.
*
* @return bool True if compatible.
*/
private function is_compatible( array $remote ): bool {
if ( ! empty( $remote['requires_php'] ) ) {
if ( ! empty( $remote['requires_php'] ) && is_string( $remote['requires_php'] ) ) {
if ( version_compare( PHP_VERSION, $remote['requires_php'], '<' ) ) {
return false;
}
}
if ( ! empty( $remote['requires'] ) ) {
if ( ! empty( $remote['requires'] ) && is_string( $remote['requires'] ) ) {
if ( version_compare( get_bloginfo( 'version' ), $remote['requires'], '<' ) ) {
return false;
}
@ -355,7 +355,7 @@ if ( ! class_exists( 'Robotstxt_Updater' ) ) {
}
// This is a cache clear request - now verify nonce.
$nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_UNSAFE_RAW );
$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_clear_update_cache' ) ) {

View file

@ -15,7 +15,7 @@ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
/**
* Delete plugin data if user opted in to data deletion.
*/
function robotstxt_og_uninstall_cleanup() {
function robotstxt_og_uninstall_cleanup(): void {
// Check if user opted in to delete data on uninstall.
$delete_data = get_option( 'robotstxt_og_delete_data_on_uninstall', false );
@ -72,7 +72,7 @@ function robotstxt_og_uninstall_cleanup() {
);
foreach ( $sites as $site ) {
switch_to_blog( $site->blog_id );
switch_to_blog( (int) $site->blog_id );
// Delete site-specific options.
delete_option( 'robotstxt_og_fallback_image' );

View file

@ -1,20 +1,20 @@
{
"name": "OpenGraph (by ROBOTSTXT)",
"slug": "robotstxt-og",
"version": "1.0.1",
"download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-og/releases/download/1.0.1/robotstxt-og-1.0.1.zip",
"requires": "6.7",
"version": "1.1.0",
"download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-og/releases/download/1.1.0/robotstxt-og-1.1.0.zip",
"requires": "6.8",
"requires_php": "8.2",
"tested": "7.0",
"last_updated": "2026-03-05",
"last_updated": "2026-03-28",
"author": "ROBOTSTXT",
"author_profile": "https://www.robotstxt.es/",
"homepage": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-og",
"description": "Intelligent Open Graph meta tags with automatic image fallback from AVIF/WebP to JPEG/PNG for social media crawlers. Outputs a complete set of og:* and twitter:* meta tags, detects incompatible image formats, and resolves JPEG/PNG alternatives via HTTP HEAD requests.",
"changelog": "<h3>1.0.1 &#8212; 2026-03-05</h3><ul><li><strong>Compatibility:</strong> Confirmed compatibility with WordPress 7.0. Supported range is now WordPress 6.7 &#8211; 7.0.</li></ul><h3>1.0.0 &#8212; 2026-02-18</h3><ul><li><strong>Initial Release:</strong> First stable release of OpenGraph (by ROBOTSTXT).</li><li><strong>Added:</strong> Full Open Graph meta tags: og:title, og:type, og:url, og:description, og:site_name, og:locale, og:image, og:image:width, og:image:height, og:image:type, og:image:alt.</li><li><strong>Added:</strong> Article tags for singular posts: article:published_time, article:modified_time, article:section, article:tag.</li><li><strong>Added:</strong> Twitter Card tags: twitter:card, twitter:site, twitter:image.</li><li><strong>Added:</strong> Automatic detection and HTTP HEAD-based resolution of JPEG/PNG alternatives for AVIF, WebP, GIF, BMP, SVG, and TIFF images.</li><li><strong>Added:</strong> Postmeta caching with automatic invalidation on featured image change.</li><li><strong>Added:</strong> Per-post OG title and description overrides via post editor meta box.</li><li><strong>Added:</strong> Global fallback image URL and homepage-specific image URL settings.</li><li><strong>Added:</strong> Yoast SEO and RankMath integration (no duplicate tags).</li><li><strong>Added:</strong> Admin settings page with Settings, Tools, and Diagnostics tabs.</li><li><strong>Added:</strong> WP-CLI commands: wp og-fallback resolve and wp og-fallback clear-cache.</li><li><strong>Added:</strong> REST API: POST /wp-json/robotstxt-og/v1/resolve/{post_id} and GET /wp-json/robotstxt-og/v1/status/{post_id}.</li><li><strong>Added:</strong> Developer filters: robotstxt_og_external_image_enabled, robotstxt_og_external_image_timeout, robotstxt_og_taxonomy_image, robotstxt_og_enable_logging.</li><li><strong>Added:</strong> Multisite compatible, full i18n support, Spanish (es_ES) translation included.</li><li><strong>Security:</strong> Nonce verification, manage_options capability checks, input sanitization, output escaping, prepared SQL statements throughout.</li></ul>",
"changelog": "<h3>1.1.0 &#8212; 2026-03-28</h3><ul><li><strong>Fixed:</strong> Fatal TypeError in handle_thumbnail_change(): the deleted_post_meta action passes an array of meta IDs as its first argument. The method signature now accepts int|array to handle both updated_post_meta and deleted_post_meta correctly.</li><li><strong>Security:</strong> Added SSRF protection to all outbound HTTP HEAD requests via is_safe_url() &#8212; private and reserved IP ranges are now blocked.</li><li><strong>Security:</strong> Replaced deprecated FILTER_SANITIZE_SPECIAL_CHARS with FILTER_SANITIZE_FULL_SPECIAL_CHARS throughout.</li><li><strong>Added:</strong> GDPR Privacy API support &#8212; custom OG title and description post meta are now included in WordPress personal data export and erase requests.</li><li><strong>Changed:</strong> Settings page and REST API now require edit_others_posts capability, allowing editors to manage OG settings and refresh fallback images.</li><li><strong>Changed:</strong> Minimum supported WordPress version raised to 6.8. Network: true header added.</li><li><strong>Added:</strong> PHPUnit test suite with Brain\\Monkey; covers SSRF logic, image format detection, and cache clearing.</li></ul><h3>1.0.0 &#8212; 2026-02-18</h3><ul><li><strong>Initial Release:</strong> First stable release of OpenGraph (by ROBOTSTXT).</li></ul>",
"sections": {
"description": "<p>Social media crawlers &#8212; Facebook, Twitter/X, LinkedIn, WhatsApp &#8212; do not support modern image formats such as AVIF and WebP. When a post&#8217;s featured image uses one of these formats, the platform shows a broken or missing image preview.</p><p><strong>OpenGraph (by ROBOTSTXT)</strong> outputs a complete set of Open Graph and Twitter Card meta tags and solves this automatically. It detects whether each post&#8217;s featured image is in a supported format and, when needed, resolves and caches a compatible JPEG or PNG alternative.</p><ul><li>Full og:* and twitter:* meta tag output</li><li>Automatic AVIF/WebP/GIF/BMP/SVG/TIFF detection with JPEG/PNG fallback</li><li>Per-post OG title and description overrides via post editor meta box</li><li>Global fallback image URL and homepage-specific image URL</li><li>Yoast SEO and RankMath integration (no duplicate tags)</li><li>Postmeta caching with automatic invalidation</li><li>Admin panel with Settings, Tools, and Diagnostics tabs</li><li>WP-CLI commands and REST API endpoints</li><li>Multisite compatible</li></ul>",
"changelog": "<h3>1.0.1 &#8212; 2026-03-05</h3><ul><li><strong>Compatibility:</strong> Confirmed compatibility with WordPress 7.0. Supported range is now WordPress 6.7 &#8211; 7.0.</li></ul><h3>1.0.0 &#8212; 2026-02-18</h3><ul><li><strong>Initial Release:</strong> First stable release of OpenGraph (by ROBOTSTXT).</li><li><strong>Added:</strong> Full Open Graph meta tags: og:title, og:type, og:url, og:description, og:site_name, og:locale, og:image, og:image:width, og:image:height, og:image:type, og:image:alt.</li><li><strong>Added:</strong> Article tags for singular posts: article:published_time, article:modified_time, article:section, article:tag.</li><li><strong>Added:</strong> Twitter Card tags: twitter:card, twitter:site, twitter:image.</li><li><strong>Added:</strong> Automatic detection and HTTP HEAD-based resolution of JPEG/PNG alternatives for AVIF, WebP, GIF, BMP, SVG, and TIFF images.</li><li><strong>Added:</strong> Postmeta caching with automatic invalidation on featured image change.</li><li><strong>Added:</strong> Per-post OG title and description overrides via post editor meta box.</li><li><strong>Added:</strong> Global fallback image URL and homepage-specific image URL settings.</li><li><strong>Added:</strong> Yoast SEO and RankMath integration (no duplicate tags).</li><li><strong>Added:</strong> Admin settings page with Settings, Tools, and Diagnostics tabs.</li><li><strong>Added:</strong> WP-CLI commands: wp og-fallback resolve and wp og-fallback clear-cache.</li><li><strong>Added:</strong> REST API: POST /wp-json/robotstxt-og/v1/resolve/{post_id} and GET /wp-json/robotstxt-og/v1/status/{post_id}.</li><li><strong>Added:</strong> Developer filters: robotstxt_og_external_image_enabled, robotstxt_og_external_image_timeout, robotstxt_og_taxonomy_image, robotstxt_og_enable_logging.</li><li><strong>Added:</strong> Multisite compatible, full i18n support, Spanish (es_ES) translation included.</li><li><strong>Security:</strong> Nonce verification, manage_options capability checks, input sanitization, output escaping, prepared SQL statements throughout.</li></ul>"
"changelog": "<h3>1.1.0 &#8212; 2026-03-28</h3><ul><li><strong>Fixed:</strong> Fatal TypeError in handle_thumbnail_change(): the deleted_post_meta action passes an array of meta IDs as its first argument. The method signature now accepts int|array to handle both updated_post_meta and deleted_post_meta correctly.</li><li><strong>Security:</strong> Added SSRF protection to all outbound HTTP HEAD requests via is_safe_url() &#8212; private and reserved IP ranges are now blocked.</li><li><strong>Security:</strong> Replaced deprecated FILTER_SANITIZE_SPECIAL_CHARS with FILTER_SANITIZE_FULL_SPECIAL_CHARS throughout.</li><li><strong>Added:</strong> GDPR Privacy API support &#8212; custom OG title and description post meta are now included in WordPress personal data export and erase requests.</li><li><strong>Changed:</strong> Settings page and REST API now require edit_others_posts capability, allowing editors to manage OG settings and refresh fallback images.</li><li><strong>Changed:</strong> Minimum supported WordPress version raised to 6.8. Network: true header added.</li><li><strong>Added:</strong> PHPUnit test suite with Brain\\Monkey; covers SSRF logic, image format detection, and cache clearing.</li></ul><h3>1.0.0 &#8212; 2026-02-18</h3><ul><li><strong>Initial Release:</strong> First stable release of OpenGraph (by ROBOTSTXT).</li></ul>"
},
"banners": {
"low": "",