| 1 |
<?php |
| 2 |
/** |
| 3 |
* Blurhash encoder + storage for image attachments. |
| 4 |
* |
| 5 |
* @package Activitypub |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace Activitypub; |
| 9 |
|
| 10 |
/** |
| 11 |
* Compute, store, and inject Blurhash placeholder strings for image |
| 12 |
* attachments so federated ActivityPub posts emit |
| 13 |
* `attachment[].blurhash` — the colored-blur preview that Pixelfed, |
| 14 |
* Mastodon, and other fediverse clients paint while the full image |
| 15 |
* is still loading. Without it, federated WP photos sit on a grey |
| 16 |
* placeholder where native uploads paint instantly; with it, the |
| 17 |
* loading state matches native. |
| 18 |
* |
| 19 |
* Encoding runs at upload time via `wp_generate_attachment_metadata`, |
| 20 |
* deferred to cron so the upload UI returns immediately. The hash |
| 21 |
* is persisted as attachment postmeta ({@see self::META_KEY}) and |
| 22 |
* read back by the `activitypub_attachment` projector — zero |
| 23 |
* per-publish CPU cost, federation hot path stays cheap. |
| 24 |
* |
| 25 |
* Pure no-op when GD isn't available: the encoder needs an |
| 26 |
* `[r,g,b][][]` pixel array, and GD's `imagecreatefrom*` family is |
| 27 |
* the only host-portable way to build one. Sites running Imagick-only |
| 28 |
* just don't get blurhash placeholders — attachments still federate, |
| 29 |
* minus the field. Same posture for any other failure (unreadable |
| 30 |
* file, encoder exception, deleted attachment): never blocks the |
| 31 |
* upload, never blocks federation. |
| 32 |
* |
| 33 |
* Called from `activitypub.php` on `init`. |
| 34 |
* Adapted from Automattic/FOSSE (https://github.com/Automattic/fosse). |
| 35 |
* |
| 36 |
* @since 9.0.0 |
| 37 |
*/ |
| 38 |
class Blurhash { |
| 39 |
|
| 40 |
/** |
| 41 |
* Postmeta key holding the encoded blurhash for an attachment. |
| 42 |
* |
| 43 |
* @var string |
| 44 |
*/ |
| 45 |
public const META_KEY = '_activitypub_blurhash'; |
| 46 |
|
| 47 |
/** |
| 48 |
* Cron hook fired for each attachment that needs a hash computed. |
| 49 |
* |
| 50 |
* @var string |
| 51 |
*/ |
| 52 |
public const CRON_HOOK = 'activitypub_blurhash_compute'; |
| 53 |
|
| 54 |
/** |
| 55 |
* DCT component count passed to the encoder as BOTH the X and |
| 56 |
* Y dimensions — kept as a single number because the two |
| 57 |
* dimensions are always equal in this encoder and splitting |
| 58 |
* them implies a tuning surface that doesn't exist. Wolt's |
| 59 |
* reference recommends 4–5 for landscape and lower for |
| 60 |
* portrait; 4 is the middle ground Mastodon also defaults to. |
| 61 |
* Hash length grows with the product, so 4×4 keeps the encoded |
| 62 |
* string short. |
| 63 |
* |
| 64 |
* @var int |
| 65 |
*/ |
| 66 |
private const COMPONENTS = 4; |
| 67 |
|
| 68 |
/** |
| 69 |
* Upper bound on stored blurhash string length, in characters. |
| 70 |
* A 4×4 component grid produces a 30-character hash; the |
| 71 |
* theoretical max for the 9×9 component grid the spec supports |
| 72 |
* is 99 characters. We cap a little above that as a defense |
| 73 |
* against postmeta poisoning — anyone with `edit_post_meta` on |
| 74 |
* an attachment could otherwise write arbitrary bytes that we |
| 75 |
* would then federate straight into the AP envelope. |
| 76 |
* |
| 77 |
* @var int |
| 78 |
*/ |
| 79 |
private const MAX_HASH_LENGTH = 128; |
| 80 |
|
| 81 |
/** |
| 82 |
* Image size used as the encoder's source. Blurhash encoding is |
| 83 |
* O(N) over pixel count and the output is a few low-frequency DCT |
| 84 |
* coefficients — feeding it a 12-megapixel original would burn |
| 85 |
* CPU producing a hash that's perceptually identical to the one |
| 86 |
* computed off the ~150px thumbnail. WP's `thumbnail` size is the |
| 87 |
* smallest variant guaranteed to exist for every uploaded image. |
| 88 |
* |
| 89 |
* @var string |
| 90 |
*/ |
| 91 |
private const ENCODE_SIZE = 'thumbnail'; |
| 92 |
|
| 93 |
/** |
| 94 |
* Hard upper bound on the longest edge fed to the encoder. Even |
| 95 |
* when {@see self::resolve_encode_path()} returns the original |
| 96 |
* (no intermediate, fallback path, misconfigured thumbnail size), |
| 97 |
* the GD image is downscaled to this max edge before the per-pixel |
| 98 |
* array is built. Keeps the PHP array allocation bounded — without |
| 99 |
* this cap, a 4000×3000 original would build a 12M-cell nested |
| 100 |
* array (~960 MB) and OOM the cron worker. |
| 101 |
* |
| 102 |
* @var int |
| 103 |
*/ |
| 104 |
private const MAX_ENCODE_EDGE = 64; |
| 105 |
|
| 106 |
/** |
| 107 |
* Hard upper bound on the byte size of the source file fed into |
| 108 |
* GD. Defends against pathological cases where {@see self::resolve_encode_path()} |
| 109 |
* resolves to a huge original (or, via filterable |
| 110 |
* `image_get_intermediate_size`, a path that's not actually an |
| 111 |
* image at all). 8 MiB comfortably accommodates any realistic |
| 112 |
* web-photo upload at full quality. |
| 113 |
* |
| 114 |
* @var int |
| 115 |
*/ |
| 116 |
private const MAX_ENCODE_BYTES = 8388608; |
| 117 |
|
| 118 |
/** |
| 119 |
* Hard upper bound on the DECODED pixel count (width × height) of |
| 120 |
* the source image. `imagecreatefromstring()` fully decodes the |
| 121 |
* compressed bytes into an uncompressed GD bitmap BEFORE |
| 122 |
* {@see self::encode_from_attachment()} downscales to |
| 123 |
* {@see self::MAX_ENCODE_EDGE}, so a small, highly compressible |
| 124 |
* source (e.g. a flat-color PNG declaring 30000×30000) slips past |
| 125 |
* the {@see self::MAX_ENCODE_BYTES} byte cap yet forces a |
| 126 |
* multi-gigabyte allocation — an uncatchable OOM that kills the |
| 127 |
* cron worker or aborts a CLI backfill mid-run. We read the |
| 128 |
* declared dimensions with `getimagesizefromstring()` first and |
| 129 |
* skip (`encode_from_attachment()` returns `false`, which callers |
| 130 |
* treat as "we don't encode this", not a failure) when the |
| 131 |
* product exceeds this cap. 50 megapixels comfortably covers any |
| 132 |
* realistic camera/phone upload while rejecting decompression bombs. |
| 133 |
* |
| 134 |
* @var int |
| 135 |
*/ |
| 136 |
private const MAX_ENCODE_PIXELS = 50000000; |
| 137 |
|
| 138 |
/** |
| 139 |
* Raster MIME types GD can decode via `imagecreatefromstring`. |
| 140 |
* Used as the early gate that splits "we don't encode this" |
| 141 |
* (skip silently) from "encoder failed" (emit warning, count |
| 142 |
* against exit status). SVG/XML, ICO, and other formats either |
| 143 |
* GD can't read or aren't raster get filtered out before the |
| 144 |
* encoder runs. |
| 145 |
* |
| 146 |
* @var array<int, string> |
| 147 |
*/ |
| 148 |
private const ENCODABLE_MIME_TYPES = array( |
| 149 |
'image/jpeg', |
| 150 |
'image/png', |
| 151 |
'image/gif', |
| 152 |
'image/webp', |
| 153 |
'image/avif', |
| 154 |
'image/bmp', |
| 155 |
); |
| 156 |
|
| 157 |
/** |
| 158 |
* Register all hooks. Called from `activitypub.php` on `init`. |
| 159 |
*/ |
| 160 |
public static function init(): void { |
| 161 |
\add_filter( 'wp_generate_attachment_metadata', array( self::class, 'schedule_encode' ), 10, 2 ); |
| 162 |
\add_action( self::CRON_HOOK, array( self::class, 'run_encode' ), 10, 1 ); |
| 163 |
\add_filter( 'activitypub_attachment', array( self::class, 'inject_blurhash' ), 10, 2 ); |
| 164 |
} |
| 165 |
|
| 166 |
/** |
| 167 |
* Return the stored blurhash for an attachment, or null when no |
| 168 |
* usable value is stored. Empty/whitespace/non-string values are |
| 169 |
* treated as absent, AND values that fail |
| 170 |
* {@see self::is_well_formed_hash()} are too — so postmeta |
| 171 |
* poisoning (or an old encoder bug that wrote junk) doesn't |
| 172 |
* leak into the federation envelope AND doesn't permanently |
| 173 |
* stick the cron `run_encode` short-circuit (which keys off |
| 174 |
* this returning non-null). Net effect: a malformed row |
| 175 |
* self-heals on the next `wp_generate_attachment_metadata` |
| 176 |
* cycle because `get()` reports absent, `run_encode` proceeds, |
| 177 |
* and `set()` overwrites the malformed value. |
| 178 |
* |
| 179 |
* @param int $attachment_id Attachment post ID. |
| 180 |
* @return string|null |
| 181 |
*/ |
| 182 |
public static function get( int $attachment_id ): ?string { |
| 183 |
$value = \get_post_meta( $attachment_id, self::META_KEY, true ); |
| 184 |
if ( ! \is_string( $value ) ) { |
| 185 |
return null; |
| 186 |
} |
| 187 |
$value = \trim( $value ); |
| 188 |
if ( '' === $value ) { |
| 189 |
return null; |
| 190 |
} |
| 191 |
return self::is_well_formed_hash( $value ) ? $value : null; |
| 192 |
} |
| 193 |
|
| 194 |
/** |
| 195 |
* Persist a computed blurhash on the attachment. |
| 196 |
* |
| 197 |
* @param int $attachment_id Attachment post ID. |
| 198 |
* @param string $hash Encoded blurhash string. |
| 199 |
*/ |
| 200 |
public static function set( int $attachment_id, string $hash ): void { |
| 201 |
\update_post_meta( $attachment_id, self::META_KEY, $hash ); |
| 202 |
} |
| 203 |
|
| 204 |
/** |
| 205 |
* Delete any stored blurhash for an attachment. Used by the |
| 206 |
* upload/regen invalidation path ({@see self::schedule_encode()}) so a |
| 207 |
* replaced image re-encodes against its latest bytes. |
| 208 |
* |
| 209 |
* @param int $attachment_id Attachment post ID. |
| 210 |
*/ |
| 211 |
public static function delete( int $attachment_id ): void { |
| 212 |
\delete_post_meta( $attachment_id, self::META_KEY ); |
| 213 |
} |
| 214 |
|
| 215 |
/** |
| 216 |
* Compute (synchronously) the blurhash for an attachment by |
| 217 |
* loading the configured size's file through GD and feeding the |
| 218 |
* pixel array to the encoder. Never throws, never warns. Used by |
| 219 |
* both the cron handler and the WP-CLI backfill. |
| 220 |
* |
| 221 |
* Three-state return so callers can route outcomes to the right |
| 222 |
* bucket: a string is success; `false` means the source is |
| 223 |
* deliberately outside encode policy and must be skipped silently — |
| 224 |
* a non-encodable attachment (non-raster mime, a deleted or |
| 225 |
* nonexistent attachment ID, or a format the host GD build can't |
| 226 |
* decode), an unavailable encoder, or a declared pixel count over |
| 227 |
* {@see self::MAX_ENCODE_PIXELS}; `null` is an unexpected failure |
| 228 |
* (the file behind an otherwise-encodable attachment is missing, |
| 229 |
* unreadable, or corrupt, or GD/the encoder errored) that callers |
| 230 |
* surface for monitoring. No native return type because union types |
| 231 |
* require PHP 8.0 and the plugin supports 7.4. |
| 232 |
* |
| 233 |
* @param int $attachment_id Attachment post ID. |
| 234 |
* @return string|false|null Hash on success, false on policy skip, null on failure. |
| 235 |
*/ |
| 236 |
public static function encode_from_attachment( int $attachment_id ) { |
| 237 |
/* |
| 238 |
* Predictable unencodability (non-raster mime, missing/deleted |
| 239 |
* attachment, host GD that can't decode the format, or no GD at |
| 240 |
* all) is a policy skip, not a failure: `false` routes direct |
| 241 |
* callers to the silent bucket instead of the diagnostic one. |
| 242 |
*/ |
| 243 |
if ( ! self::is_encodable_attachment( $attachment_id ) ) { |
| 244 |
return false; |
| 245 |
} |
| 246 |
|
| 247 |
if ( ! self::is_encoder_runnable() ) { |
| 248 |
return false; |
| 249 |
} |
| 250 |
|
| 251 |
$path = self::resolve_encode_path( $attachment_id ); |
| 252 |
if ( null === $path ) { |
| 253 |
return null; |
| 254 |
} |
| 255 |
|
| 256 |
// Fast-fail before reading bytes. A pathological source |
| 257 |
// (huge original, non-image file slipped through a filterable |
| 258 |
// metadata path) gets rejected without allocating PHP memory |
| 259 |
// for the read. |
| 260 |
$size = @\filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- stat failure returns false and we handle. |
| 261 |
if ( false === $size || $size < 1 || $size > self::MAX_ENCODE_BYTES ) { |
| 262 |
return null; |
| 263 |
} |
| 264 |
|
| 265 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- local absolute path read; corrupt/missing file returns false and we handle. |
| 266 |
$bytes = @\file_get_contents( $path ); |
| 267 |
if ( false === $bytes || '' === $bytes ) { |
| 268 |
return null; |
| 269 |
} |
| 270 |
|
| 271 |
/* |
| 272 |
* Decode-bomb guard. `imagecreatefromstring()` fully decodes |
| 273 |
* the compressed bytes into an uncompressed bitmap before we |
| 274 |
* get a chance to downscale, so a small but highly |
| 275 |
* compressible source declaring huge dimensions (e.g. a |
| 276 |
* flat-color 30000×30000 PNG) would force a multi-gigabyte |
| 277 |
* allocation and OOM the worker — uncatchable, so we can't |
| 278 |
* recover with the try/catch below. Read the declared |
| 279 |
* dimensions cheaply first and skip (silent, not an error) |
| 280 |
* anything past the megapixel cap. |
| 281 |
*/ |
| 282 |
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- malformed header returns false and we handle. |
| 283 |
$dimensions = @\getimagesizefromstring( $bytes ); |
| 284 |
if ( false === $dimensions || ! isset( $dimensions[0] ) || ! isset( $dimensions[1] ) ) { |
| 285 |
return null; |
| 286 |
} |
| 287 |
$declared_width = (int) $dimensions[0]; |
| 288 |
$declared_height = (int) $dimensions[1]; |
| 289 |
if ( $declared_width < 1 || $declared_height < 1 ) { |
| 290 |
return null; |
| 291 |
} |
| 292 |
if ( $declared_width * $declared_height > self::MAX_ENCODE_PIXELS ) { |
| 293 |
/* |
| 294 |
* Policy skip, not a failure: `false` routes the caller |
| 295 |
* to the same silent bucket as a non-raster mime, so a |
| 296 |
* permanently over-cap source doesn't error_log on every |
| 297 |
* cron run or hold the CLI backfill exit code nonzero |
| 298 |
* forever. |
| 299 |
*/ |
| 300 |
return false; |
| 301 |
} |
| 302 |
|
| 303 |
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- corrupt image returns false and we handle. |
| 304 |
$original = @\imagecreatefromstring( $bytes ); |
| 305 |
if ( false === $original ) { |
| 306 |
return null; |
| 307 |
} |
| 308 |
|
| 309 |
/* |
| 310 |
* $scaled holds a second GD resource created by imagescale when |
| 311 |
* the image exceeds MAX_ENCODE_EDGE, $canvas a third one |
| 312 |
* created for the transparency flattening composite. Both are |
| 313 |
* kept separate from $original so all of them can be destroyed |
| 314 |
* in finally regardless of which code path ran. |
| 315 |
*/ |
| 316 |
$scaled = null; |
| 317 |
$canvas = null; |
| 318 |
|
| 319 |
try { |
| 320 |
$width = \imagesx( $original ); |
| 321 |
$height = \imagesy( $original ); |
| 322 |
if ( $width < 1 || $height < 1 ) { |
| 323 |
return null; |
| 324 |
} |
| 325 |
|
| 326 |
// The image we will actually read pixels from — either the |
| 327 |
// original or a downscaled copy. |
| 328 |
$src_image = $original; |
| 329 |
|
| 330 |
// Defensive downscale before the per-pixel loop. The |
| 331 |
// nested array grows quadratically with edge length, so |
| 332 |
// any source larger than MAX_ENCODE_EDGE gets sampled |
| 333 |
// down — perceptually identical output, bounded memory. |
| 334 |
// |
| 335 |
// Scale by the LONGER edge so portrait images |
| 336 |
// (`height > width`) don't get upscaled by a |
| 337 |
// fixed-width call to `imagescale`. Target dimensions |
| 338 |
// are computed explicitly so both edges land at or |
| 339 |
// below the cap. If `imagescale` fails we bail rather |
| 340 |
// than fall through to the per-pixel loop with the |
| 341 |
// original (oversized) GD image. |
| 342 |
if ( $width > self::MAX_ENCODE_EDGE || $height > self::MAX_ENCODE_EDGE ) { |
| 343 |
if ( $width >= $height ) { |
| 344 |
$target_width = self::MAX_ENCODE_EDGE; |
| 345 |
$target_height = (int) \max( 1, \round( $height * ( self::MAX_ENCODE_EDGE / $width ) ) ); |
| 346 |
} else { |
| 347 |
$target_height = self::MAX_ENCODE_EDGE; |
| 348 |
$target_width = (int) \max( 1, \round( $width * ( self::MAX_ENCODE_EDGE / $height ) ) ); |
| 349 |
} |
| 350 |
$scaled = \imagescale( $original, $target_width, $target_height ); |
| 351 |
if ( false === $scaled ) { |
| 352 |
return null; |
| 353 |
} |
| 354 |
$src_image = $scaled; |
| 355 |
$width = $target_width; |
| 356 |
$height = $target_height; |
| 357 |
} |
| 358 |
|
| 359 |
/* |
| 360 |
* Flatten transparency against a white background before |
| 361 |
* sampling. `imagecolorsforindex()` reports the raw RGB of |
| 362 |
* a transparent pixel (usually 0,0,0 → black) while |
| 363 |
* discarding alpha, so a transparent PNG/GIF/WebP logo or |
| 364 |
* sticker would otherwise encode to a near-black blurhash. |
| 365 |
* Compositing onto an opaque white canvas yields the color |
| 366 |
* a viewer actually sees over a typical light surface. |
| 367 |
* Best-effort: if any GD call fails we keep sampling the |
| 368 |
* un-flattened image rather than bail. |
| 369 |
*/ |
| 370 |
$canvas = \imagecreatetruecolor( $width, $height ); |
| 371 |
if ( false !== $canvas ) { |
| 372 |
$white = \imagecolorallocate( $canvas, 255, 255, 255 ); |
| 373 |
if ( false !== $white ) { |
| 374 |
\imagefilledrectangle( $canvas, 0, 0, $width - 1, $height - 1, $white ); |
| 375 |
\imagealphablending( $canvas, true ); |
| 376 |
if ( \imagecopy( $canvas, $src_image, 0, 0, 0, 0, $width, $height ) ) { |
| 377 |
$src_image = $canvas; |
| 378 |
} |
| 379 |
} |
| 380 |
} |
| 381 |
|
| 382 |
$pixels = array(); |
| 383 |
for ( $y = 0; $y < $height; $y++ ) { |
| 384 |
$row = array(); |
| 385 |
for ( $x = 0; $x < $width; $x++ ) { |
| 386 |
$index = \imagecolorat( $src_image, $x, $y ); |
| 387 |
$colors = \imagecolorsforindex( $src_image, $index ); |
| 388 |
$row[] = array( $colors['red'], $colors['green'], $colors['blue'] ); |
| 389 |
} |
| 390 |
$pixels[] = $row; |
| 391 |
} |
| 392 |
|
| 393 |
$hash = Blurhash_Encoder::encode( $pixels, self::COMPONENTS, self::COMPONENTS ); |
| 394 |
return \is_string( $hash ) && '' !== $hash ? $hash : null; |
| 395 |
} catch ( \Throwable $e ) { |
| 396 |
return null; |
| 397 |
} finally { |
| 398 |
// Free GD resources. On PHP 7.4 GD images are plain |
| 399 |
// resources that persist until script end; the CLI |
| 400 |
// backfill processes many images in one process and |
| 401 |
// would leak all of them without explicit cleanup. |
| 402 |
\imagedestroy( $original ); |
| 403 |
if ( null !== $scaled && false !== $scaled ) { |
| 404 |
\imagedestroy( $scaled ); |
| 405 |
} |
| 406 |
if ( null !== $canvas && false !== $canvas ) { |
| 407 |
\imagedestroy( $canvas ); |
| 408 |
} |
| 409 |
} |
| 410 |
} |
| 411 |
|
| 412 |
/** |
| 413 |
* Resolve the absolute filesystem path of the size we use as |
| 414 |
* encoder input, falling back to the original when the |
| 415 |
* intermediate doesn't exist (small uploads that core didn't |
| 416 |
* generate downscales for). |
| 417 |
* |
| 418 |
* @param int $attachment_id Attachment post ID. |
| 419 |
* @return string|null Absolute file path, or null when nothing readable resolved. |
| 420 |
*/ |
| 421 |
private static function resolve_encode_path( int $attachment_id ): ?string { |
| 422 |
$upload = \wp_upload_dir(); |
| 423 |
$basedir_real = ( \is_array( $upload ) && ! empty( $upload['basedir'] ) ) |
| 424 |
? \realpath( $upload['basedir'] ) |
| 425 |
: false; |
| 426 |
|
| 427 |
$sized = \image_get_intermediate_size( $attachment_id, self::ENCODE_SIZE ); |
| 428 |
if ( \is_array( $sized ) && ! empty( $sized['path'] ) && false !== $basedir_real ) { |
| 429 |
$candidate = \trailingslashit( $upload['basedir'] ) . $sized['path']; |
| 430 |
$resolved = self::contain_under_basedir( $candidate, $basedir_real ); |
| 431 |
if ( null !== $resolved ) { |
| 432 |
return $resolved; |
| 433 |
} |
| 434 |
} |
| 435 |
|
| 436 |
$attached = \get_attached_file( $attachment_id ); |
| 437 |
if ( \is_string( $attached ) && '' !== $attached ) { |
| 438 |
// The fallback can return paths outside the uploads dir |
| 439 |
// (e.g. shared media), so containment is best-effort: we |
| 440 |
// accept readable real paths but skip the basedir prefix |
| 441 |
// check, while still rejecting unreadable / non-existent |
| 442 |
// targets. |
| 443 |
$resolved = \realpath( $attached ); |
| 444 |
if ( false !== $resolved && \is_readable( $resolved ) ) { |
| 445 |
return $resolved; |
| 446 |
} |
| 447 |
} |
| 448 |
|
| 449 |
return null; |
| 450 |
} |
| 451 |
|
| 452 |
/** |
| 453 |
* Realpath-resolve `$candidate` and verify it sits under |
| 454 |
* `$basedir_real`. Returns the resolved path on success, null on |
| 455 |
* any failure (unresolvable, traversal outside the basedir, |
| 456 |
* unreadable). Defends the encoder against filterable |
| 457 |
* `image_get_intermediate_size` returning a `path` that contains |
| 458 |
* `..` segments or an absolute symlink target outside uploads. |
| 459 |
* |
| 460 |
* @param string $candidate Path to resolve. |
| 461 |
* @param string $basedir_real Already-resolved (realpath) basedir. |
| 462 |
* @return string|null |
| 463 |
*/ |
| 464 |
private static function contain_under_basedir( string $candidate, string $basedir_real ): ?string { |
| 465 |
$resolved = \realpath( $candidate ); |
| 466 |
if ( false === $resolved ) { |
| 467 |
return null; |
| 468 |
} |
| 469 |
$prefix = \rtrim( $basedir_real, \DIRECTORY_SEPARATOR ) . \DIRECTORY_SEPARATOR; |
| 470 |
if ( 0 !== \strpos( $resolved, $prefix ) ) { |
| 471 |
return null; |
| 472 |
} |
| 473 |
return \is_readable( $resolved ) ? $resolved : null; |
| 474 |
} |
| 475 |
|
| 476 |
/** |
| 477 |
* `wp_generate_attachment_metadata` filter callback. Schedules |
| 478 |
* a single-event cron run to compute the blurhash for image |
| 479 |
* attachments. The filter return value is the metadata unchanged |
| 480 |
* — we use the hook purely as an "image is ready" notifier. |
| 481 |
* |
| 482 |
* @param array $metadata Attachment metadata as built by WP. |
| 483 |
* @param int $attachment_id Attachment post ID. |
| 484 |
* @return array |
| 485 |
*/ |
| 486 |
public static function schedule_encode( $metadata, $attachment_id ) { |
| 487 |
$attachment_id = (int) $attachment_id; |
| 488 |
if ( $attachment_id < 1 || ! self::is_encodable_attachment( $attachment_id ) ) { |
| 489 |
return $metadata; |
| 490 |
} |
| 491 |
|
| 492 |
// Skip both the invalidation AND the cron enqueue when the |
| 493 |
// encoder can't actually run on this host. Without the gate, |
| 494 |
// a metadata regen on a GD-less site would wipe a previously |
| 495 |
// stored hash (computed on a different host, restored from |
| 496 |
// backup, etc.) and queue a cron event guaranteed to fail. |
| 497 |
if ( ! self::is_encoder_runnable() ) { |
| 498 |
return $metadata; |
| 499 |
} |
| 500 |
|
| 501 |
// Invalidate any prior hash so cron will re-encode against |
| 502 |
// the latest bytes. `wp_generate_attachment_metadata` fires |
| 503 |
// on initial upload AND on media-replace / crop / regen, so |
| 504 |
// without this delete a replaced image would keep federating |
| 505 |
// the placeholder for its prior bytes. |
| 506 |
self::delete( $attachment_id ); |
| 507 |
|
| 508 |
self::schedule( $attachment_id ); |
| 509 |
return $metadata; |
| 510 |
} |
| 511 |
|
| 512 |
/** |
| 513 |
* Whether an attachment is something the encoder will attempt on this host. |
| 514 |
* True for `wp_attachment_is_image` attachments whose mime is in |
| 515 |
* {@see self::ENCODABLE_MIME_TYPES} AND that this GD build can actually |
| 516 |
* decode; false for SVG, non-image media, deleted/nonexistent IDs, and |
| 517 |
* formats this GD build lacks support for (e.g. WebP/AVIF/BMP on a |
| 518 |
* stripped-down GD). Shared gate used by the upload-scheduling path, the |
| 519 |
* CLI backfill (to count "we don't encode this" as a skip rather than a |
| 520 |
* failure), and the encoder itself. |
| 521 |
* |
| 522 |
* The GD-capability check matters because `schedule_encode()` invalidates |
| 523 |
* any prior hash before queueing the cron encode: without it, a metadata |
| 524 |
* regen on a host that can't decode the format would wipe a previously |
| 525 |
* good hash (e.g. migrated from a host with broader GD support) and queue |
| 526 |
* a cron event guaranteed to fail. |
| 527 |
* |
| 528 |
* @param int $attachment_id Attachment post ID. |
| 529 |
* @return bool |
| 530 |
*/ |
| 531 |
public static function is_encodable_attachment( int $attachment_id ): bool { |
| 532 |
if ( $attachment_id < 1 || ! \wp_attachment_is_image( $attachment_id ) ) { |
| 533 |
return false; |
| 534 |
} |
| 535 |
$mime = \get_post_mime_type( $attachment_id ); |
| 536 |
return \is_string( $mime ) |
| 537 |
&& \in_array( $mime, self::ENCODABLE_MIME_TYPES, true ) |
| 538 |
&& self::host_can_decode( $mime ); |
| 539 |
} |
| 540 |
|
| 541 |
/** |
| 542 |
* Whether this GD build can decode the given image mime type. |
| 543 |
* |
| 544 |
* GD support for WebP, AVIF, and BMP is build-dependent, so a mime being |
| 545 |
* in {@see self::ENCODABLE_MIME_TYPES} is necessary but not sufficient. |
| 546 |
* `imagetypes()` reports what the running GD can actually handle. The |
| 547 |
* `IMG_*` flags for WebP/AVIF/BMP are not defined on every PHP version |
| 548 |
* (`IMG_AVIF` is PHP 8.1+), so guard each with `defined()`. |
| 549 |
* |
| 550 |
* @param string $mime The attachment mime type. |
| 551 |
* @return bool |
| 552 |
*/ |
| 553 |
private static function host_can_decode( $mime ) { |
| 554 |
if ( ! \function_exists( 'imagetypes' ) ) { |
| 555 |
return false; |
| 556 |
} |
| 557 |
|
| 558 |
$supported = \imagetypes(); |
| 559 |
|
| 560 |
switch ( $mime ) { |
| 561 |
case 'image/jpeg': |
| 562 |
return (bool) ( $supported & IMG_JPG ); |
| 563 |
case 'image/png': |
| 564 |
return (bool) ( $supported & IMG_PNG ); |
| 565 |
case 'image/gif': |
| 566 |
return (bool) ( $supported & IMG_GIF ); |
| 567 |
case 'image/webp': |
| 568 |
return \defined( 'IMG_WEBP' ) && (bool) ( $supported & IMG_WEBP ); |
| 569 |
case 'image/avif': |
| 570 |
return \defined( 'IMG_AVIF' ) && (bool) ( $supported & IMG_AVIF ); |
| 571 |
case 'image/bmp': |
| 572 |
return \defined( 'IMG_BMP' ) && (bool) ( $supported & IMG_BMP ); |
| 573 |
default: |
| 574 |
return false; |
| 575 |
} |
| 576 |
} |
| 577 |
|
| 578 |
/** |
| 579 |
* Whether the host has the GD primitives the encoder needs to |
| 580 |
* actually run. Public so the WP-CLI backfill can fail-fast with |
| 581 |
* one clear error message rather than emit a warning per |
| 582 |
* attachment, and so the upload/cron paths can short-circuit |
| 583 |
* without leaving cron noise behind on a GD-less host. |
| 584 |
* |
| 585 |
* @return bool |
| 586 |
*/ |
| 587 |
public static function is_encoder_runnable(): bool { |
| 588 |
return \function_exists( 'imagecreatefromstring' ) |
| 589 |
&& \function_exists( 'imagecreatetruecolor' ) |
| 590 |
&& \function_exists( 'imagescale' ); |
| 591 |
} |
| 592 |
|
| 593 |
/** |
| 594 |
* Queue (or skip, if already queued) a cron event to compute |
| 595 |
* the blurhash for an attachment. Internal helper for |
| 596 |
* {@see self::schedule_encode()} — callers should go through |
| 597 |
* that filter callback so the metadata-regen invalidation pass |
| 598 |
* stays load-bearing. |
| 599 |
* |
| 600 |
* @param int $attachment_id Attachment post ID. |
| 601 |
*/ |
| 602 |
private static function schedule( int $attachment_id ): void { |
| 603 |
if ( $attachment_id < 1 ) { |
| 604 |
return; |
| 605 |
} |
| 606 |
|
| 607 |
/* |
| 608 |
* Rely on WP's own duplicate-event guard inside |
| 609 |
* `wp_schedule_single_event` (rejects matching args within |
| 610 |
* the 10-minute window) rather than running an explicit |
| 611 |
* `wp_next_scheduled` check first. The explicit check did |
| 612 |
* nothing the underlying scheduler doesn't already do and |
| 613 |
* added a needless read against the autoloaded cron option |
| 614 |
* on every attachment metadata regen. |
| 615 |
* |
| 616 |
* Defer one minute rather than firing at `time()`. This |
| 617 |
* callback runs inside the `wp_generate_attachment_metadata` |
| 618 |
* filter, BEFORE `wp_update_attachment_metadata()` commits the |
| 619 |
* sizes array. A concurrent wp-cron tick could otherwise run |
| 620 |
* `run_encode()` before that commit lands, find no thumbnail |
| 621 |
* intermediate yet, and fall back to encoding the full-size |
| 622 |
* original. The one-minute delay lets the metadata write |
| 623 |
* settle first; single-event dedup semantics are unchanged. |
| 624 |
*/ |
| 625 |
\wp_schedule_single_event( \time() + MINUTE_IN_SECONDS, self::CRON_HOOK, array( $attachment_id ) ); |
| 626 |
} |
| 627 |
|
| 628 |
/** |
| 629 |
* Cron callback: compute and store the blurhash. No-op when a |
| 630 |
* hash is already stored; callers wanting a re-encode delete |
| 631 |
* the postmeta first ({@see self::delete()}) — that's what |
| 632 |
* {@see self::schedule_encode()} does before scheduling, so the |
| 633 |
* media-replace path always recomputes. |
| 634 |
* |
| 635 |
* Emits `activitypub_blurhash_encode_failed` (action) and an |
| 636 |
* `error_log` line when the encoder returns null without a |
| 637 |
* pre-existing stored hash, so transient failures (NFS hiccup, |
| 638 |
* S3 lag, GD blip) leave a signal for monitoring instead of |
| 639 |
* silently never producing a placeholder. |
| 640 |
* |
| 641 |
* @param int $attachment_id Attachment post ID. |
| 642 |
*/ |
| 643 |
public static function run_encode( int $attachment_id ): void { |
| 644 |
$attachment_id = (int) $attachment_id; |
| 645 |
if ( $attachment_id < 1 ) { |
| 646 |
return; |
| 647 |
} |
| 648 |
|
| 649 |
// Predictable unencodability (system-wide GD missing, |
| 650 |
// non-raster mime, deleted attachment) is silent — logging |
| 651 |
// per-event would spam diagnostics on every scheduled run |
| 652 |
// even though the reason is global. Only unexpected failures |
| 653 |
// (encoder exception, missing file, decode failure) below |
| 654 |
// fire the diagnostic action. |
| 655 |
if ( ! self::is_encoder_runnable() || ! self::is_encodable_attachment( $attachment_id ) ) { |
| 656 |
return; |
| 657 |
} |
| 658 |
|
| 659 |
if ( null !== self::get( $attachment_id ) ) { |
| 660 |
return; |
| 661 |
} |
| 662 |
$hash = self::encode_from_attachment( $attachment_id ); |
| 663 |
if ( \is_string( $hash ) ) { |
| 664 |
self::set( $attachment_id, $hash ); |
| 665 |
return; |
| 666 |
} |
| 667 |
|
| 668 |
/* |
| 669 |
* `false` is a policy skip (source over the decode-bomb |
| 670 |
* dimension cap): deliberate, deterministic, and global to |
| 671 |
* the source bytes — logging it would re-introduce the |
| 672 |
* per-run noise this bucket exists to avoid. Only `null` |
| 673 |
* (unexpected failure) falls through to diagnostics. |
| 674 |
*/ |
| 675 |
if ( false === $hash ) { |
| 676 |
return; |
| 677 |
} |
| 678 |
|
| 679 |
// Silent-failure guard — surface the gap so an operator can |
| 680 |
// investigate (or wire monitoring against the action). |
| 681 |
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- intentional plugin diagnostics; cron path only. |
| 682 |
\error_log( "[activitypub:blurhash] encode failed for attachment {$attachment_id}; placeholder will be absent until backfill." ); |
| 683 |
|
| 684 |
/** |
| 685 |
* Fires when the cron-deferred encoder fails to produce a |
| 686 |
* hash for an attachment. Monitoring integrations can hook |
| 687 |
* this to count blurhash-encode failures over time. |
| 688 |
* |
| 689 |
* @param int $attachment_id The attachment ID that failed to encode. |
| 690 |
*/ |
| 691 |
\do_action( 'activitypub_blurhash_encode_failed', $attachment_id ); |
| 692 |
} |
| 693 |
|
| 694 |
/** |
| 695 |
* `activitypub_attachment` filter callback. Injects `blurhash` |
| 696 |
* into image attachment arrays when a usable postmeta value is |
| 697 |
* stored. No-op on anything else (non-image attachments, missing |
| 698 |
* meta, malformed meta, malformed arrays) so non-photo federation |
| 699 |
* paths are untouched. Sanitization is enforced inside |
| 700 |
* {@see self::get()} — anyone with `edit_post_meta` on an |
| 701 |
* attachment could otherwise rewrite `_activitypub_blurhash` to bytes |
| 702 |
* that break `wp_json_encode` and drop the entire AP envelope. |
| 703 |
* |
| 704 |
* @param mixed $attachment The attachment array as built by bundled AP. |
| 705 |
* @param mixed $attachment_id The attachment post ID (mixed because the upstream filter is loosely typed). |
| 706 |
* @return mixed |
| 707 |
*/ |
| 708 |
public static function inject_blurhash( $attachment, $attachment_id ) { |
| 709 |
if ( ! \is_array( $attachment ) ) { |
| 710 |
return $attachment; |
| 711 |
} |
| 712 |
if ( 'Image' !== ( $attachment['type'] ?? '' ) ) { |
| 713 |
return $attachment; |
| 714 |
} |
| 715 |
$hash = self::get( (int) $attachment_id ); |
| 716 |
if ( null === $hash ) { |
| 717 |
return $attachment; |
| 718 |
} |
| 719 |
$attachment['blurhash'] = $hash; |
| 720 |
return $attachment; |
| 721 |
} |
| 722 |
|
| 723 |
/** |
| 724 |
* Validate a stored hash against the blurhash spec's character |
| 725 |
* set and our length bound. Treat any out-of-bounds value as |
| 726 |
* absent rather than coerce — preserves the "no blurhash is |
| 727 |
* better than a wrong blurhash" posture the rest of the class |
| 728 |
* follows. The base83 alphabet is defined by the spec at |
| 729 |
* {@link https://github.com/woltapp/blurhash/blob/master/Algorithm.md#base-83}. |
| 730 |
* |
| 731 |
* @param string $hash Candidate hash string. |
| 732 |
* @return bool |
| 733 |
*/ |
| 734 |
private static function is_well_formed_hash( string $hash ): bool { |
| 735 |
$length = \strlen( $hash ); |
| 736 |
if ( $length < 6 || $length > self::MAX_HASH_LENGTH ) { |
| 737 |
return false; |
| 738 |
} |
| 739 |
// Base83 alphabet — same character set the encoder library |
| 740 |
// emits, conservative enough to reject any byte sequence |
| 741 |
// that would surprise a downstream JSON encoder or client |
| 742 |
// decoder. |
| 743 |
return 1 === \preg_match( '/\A[0-9A-Za-z#$%*+,\-.:;=?@\[\]\^_{|}~]+\z/', $hash ); |
| 744 |
} |
| 745 |
} |
| 746 |
|