PluginProbe
ActivityPub / trunk
ActivityPub vtrunk
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / class-blurhash.php

class-blurhash.php in ActivityPub trunk, at includes/class-blurhash.php

746 lines 28.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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