*/ private const ENCODABLE_MIME_TYPES = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/bmp', ); /** * Register all hooks. Called from `activitypub.php` on `init`. */ public static function init(): void { \add_filter( 'wp_generate_attachment_metadata', array( self::class, 'schedule_encode' ), 10, 2 ); \add_action( self::CRON_HOOK, array( self::class, 'run_encode' ), 10, 1 ); \add_filter( 'activitypub_attachment', array( self::class, 'inject_blurhash' ), 10, 2 ); } /** * Return the stored blurhash for an attachment, or null when no * usable value is stored. Empty/whitespace/non-string values are * treated as absent, AND values that fail * {@see self::is_well_formed_hash()} are too — so postmeta * poisoning (or an old encoder bug that wrote junk) doesn't * leak into the federation envelope AND doesn't permanently * stick the cron `run_encode` short-circuit (which keys off * this returning non-null). Net effect: a malformed row * self-heals on the next `wp_generate_attachment_metadata` * cycle because `get()` reports absent, `run_encode` proceeds, * and `set()` overwrites the malformed value. * * @param int $attachment_id Attachment post ID. * @return string|null */ public static function get( int $attachment_id ): ?string { $value = \get_post_meta( $attachment_id, self::META_KEY, true ); if ( ! \is_string( $value ) ) { return null; } $value = \trim( $value ); if ( '' === $value ) { return null; } return self::is_well_formed_hash( $value ) ? $value : null; } /** * Persist a computed blurhash on the attachment. * * @param int $attachment_id Attachment post ID. * @param string $hash Encoded blurhash string. */ public static function set( int $attachment_id, string $hash ): void { \update_post_meta( $attachment_id, self::META_KEY, $hash ); } /** * Delete any stored blurhash for an attachment. Used by the * upload/regen invalidation path ({@see self::schedule_encode()}) so a * replaced image re-encodes against its latest bytes. * * @param int $attachment_id Attachment post ID. */ public static function delete( int $attachment_id ): void { \delete_post_meta( $attachment_id, self::META_KEY ); } /** * Compute (synchronously) the blurhash for an attachment by * loading the configured size's file through GD and feeding the * pixel array to the encoder. Never throws, never warns. Used by * both the cron handler and the WP-CLI backfill. * * Three-state return so callers can route outcomes to the right * bucket: a string is success; `false` means the source is * deliberately outside encode policy and must be skipped silently — * a non-encodable attachment (non-raster mime, a deleted or * nonexistent attachment ID, or a format the host GD build can't * decode), an unavailable encoder, or a declared pixel count over * {@see self::MAX_ENCODE_PIXELS}; `null` is an unexpected failure * (the file behind an otherwise-encodable attachment is missing, * unreadable, or corrupt, or GD/the encoder errored) that callers * surface for monitoring. No native return type because union types * require PHP 8.0 and the plugin supports 7.4. * * @param int $attachment_id Attachment post ID. * @return string|false|null Hash on success, false on policy skip, null on failure. */ public static function encode_from_attachment( int $attachment_id ) { /* * Predictable unencodability (non-raster mime, missing/deleted * attachment, host GD that can't decode the format, or no GD at * all) is a policy skip, not a failure: `false` routes direct * callers to the silent bucket instead of the diagnostic one. */ if ( ! self::is_encodable_attachment( $attachment_id ) ) { return false; } if ( ! self::is_encoder_runnable() ) { return false; } $path = self::resolve_encode_path( $attachment_id ); if ( null === $path ) { return null; } // Fast-fail before reading bytes. A pathological source // (huge original, non-image file slipped through a filterable // metadata path) gets rejected without allocating PHP memory // for the read. $size = @\filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- stat failure returns false and we handle. if ( false === $size || $size < 1 || $size > self::MAX_ENCODE_BYTES ) { return null; } // 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. $bytes = @\file_get_contents( $path ); if ( false === $bytes || '' === $bytes ) { return null; } /* * Decode-bomb guard. `imagecreatefromstring()` fully decodes * the compressed bytes into an uncompressed bitmap before we * get a chance to downscale, so a small but highly * compressible source declaring huge dimensions (e.g. a * flat-color 30000×30000 PNG) would force a multi-gigabyte * allocation and OOM the worker — uncatchable, so we can't * recover with the try/catch below. Read the declared * dimensions cheaply first and skip (silent, not an error) * anything past the megapixel cap. */ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- malformed header returns false and we handle. $dimensions = @\getimagesizefromstring( $bytes ); if ( false === $dimensions || ! isset( $dimensions[0] ) || ! isset( $dimensions[1] ) ) { return null; } $declared_width = (int) $dimensions[0]; $declared_height = (int) $dimensions[1]; if ( $declared_width < 1 || $declared_height < 1 ) { return null; } if ( $declared_width * $declared_height > self::MAX_ENCODE_PIXELS ) { /* * Policy skip, not a failure: `false` routes the caller * to the same silent bucket as a non-raster mime, so a * permanently over-cap source doesn't error_log on every * cron run or hold the CLI backfill exit code nonzero * forever. */ return false; } // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- corrupt image returns false and we handle. $original = @\imagecreatefromstring( $bytes ); if ( false === $original ) { return null; } /* * $scaled holds a second GD resource created by imagescale when * the image exceeds MAX_ENCODE_EDGE, $canvas a third one * created for the transparency flattening composite. Both are * kept separate from $original so all of them can be destroyed * in finally regardless of which code path ran. */ $scaled = null; $canvas = null; try { $width = \imagesx( $original ); $height = \imagesy( $original ); if ( $width < 1 || $height < 1 ) { return null; } // The image we will actually read pixels from — either the // original or a downscaled copy. $src_image = $original; // Defensive downscale before the per-pixel loop. The // nested array grows quadratically with edge length, so // any source larger than MAX_ENCODE_EDGE gets sampled // down — perceptually identical output, bounded memory. // // Scale by the LONGER edge so portrait images // (`height > width`) don't get upscaled by a // fixed-width call to `imagescale`. Target dimensions // are computed explicitly so both edges land at or // below the cap. If `imagescale` fails we bail rather // than fall through to the per-pixel loop with the // original (oversized) GD image. if ( $width > self::MAX_ENCODE_EDGE || $height > self::MAX_ENCODE_EDGE ) { if ( $width >= $height ) { $target_width = self::MAX_ENCODE_EDGE; $target_height = (int) \max( 1, \round( $height * ( self::MAX_ENCODE_EDGE / $width ) ) ); } else { $target_height = self::MAX_ENCODE_EDGE; $target_width = (int) \max( 1, \round( $width * ( self::MAX_ENCODE_EDGE / $height ) ) ); } $scaled = \imagescale( $original, $target_width, $target_height ); if ( false === $scaled ) { return null; } $src_image = $scaled; $width = $target_width; $height = $target_height; } /* * Flatten transparency against a white background before * sampling. `imagecolorsforindex()` reports the raw RGB of * a transparent pixel (usually 0,0,0 → black) while * discarding alpha, so a transparent PNG/GIF/WebP logo or * sticker would otherwise encode to a near-black blurhash. * Compositing onto an opaque white canvas yields the color * a viewer actually sees over a typical light surface. * Best-effort: if any GD call fails we keep sampling the * un-flattened image rather than bail. */ $canvas = \imagecreatetruecolor( $width, $height ); if ( false !== $canvas ) { $white = \imagecolorallocate( $canvas, 255, 255, 255 ); if ( false !== $white ) { \imagefilledrectangle( $canvas, 0, 0, $width - 1, $height - 1, $white ); \imagealphablending( $canvas, true ); if ( \imagecopy( $canvas, $src_image, 0, 0, 0, 0, $width, $height ) ) { $src_image = $canvas; } } } $pixels = array(); for ( $y = 0; $y < $height; $y++ ) { $row = array(); for ( $x = 0; $x < $width; $x++ ) { $index = \imagecolorat( $src_image, $x, $y ); $colors = \imagecolorsforindex( $src_image, $index ); $row[] = array( $colors['red'], $colors['green'], $colors['blue'] ); } $pixels[] = $row; } $hash = Blurhash_Encoder::encode( $pixels, self::COMPONENTS, self::COMPONENTS ); return \is_string( $hash ) && '' !== $hash ? $hash : null; } catch ( \Throwable $e ) { return null; } finally { // Free GD resources. On PHP 7.4 GD images are plain // resources that persist until script end; the CLI // backfill processes many images in one process and // would leak all of them without explicit cleanup. \imagedestroy( $original ); if ( null !== $scaled && false !== $scaled ) { \imagedestroy( $scaled ); } if ( null !== $canvas && false !== $canvas ) { \imagedestroy( $canvas ); } } } /** * Resolve the absolute filesystem path of the size we use as * encoder input, falling back to the original when the * intermediate doesn't exist (small uploads that core didn't * generate downscales for). * * @param int $attachment_id Attachment post ID. * @return string|null Absolute file path, or null when nothing readable resolved. */ private static function resolve_encode_path( int $attachment_id ): ?string { $upload = \wp_upload_dir(); $basedir_real = ( \is_array( $upload ) && ! empty( $upload['basedir'] ) ) ? \realpath( $upload['basedir'] ) : false; $sized = \image_get_intermediate_size( $attachment_id, self::ENCODE_SIZE ); if ( \is_array( $sized ) && ! empty( $sized['path'] ) && false !== $basedir_real ) { $candidate = \trailingslashit( $upload['basedir'] ) . $sized['path']; $resolved = self::contain_under_basedir( $candidate, $basedir_real ); if ( null !== $resolved ) { return $resolved; } } $attached = \get_attached_file( $attachment_id ); if ( \is_string( $attached ) && '' !== $attached ) { // The fallback can return paths outside the uploads dir // (e.g. shared media), so containment is best-effort: we // accept readable real paths but skip the basedir prefix // check, while still rejecting unreadable / non-existent // targets. $resolved = \realpath( $attached ); if ( false !== $resolved && \is_readable( $resolved ) ) { return $resolved; } } return null; } /** * Realpath-resolve `$candidate` and verify it sits under * `$basedir_real`. Returns the resolved path on success, null on * any failure (unresolvable, traversal outside the basedir, * unreadable). Defends the encoder against filterable * `image_get_intermediate_size` returning a `path` that contains * `..` segments or an absolute symlink target outside uploads. * * @param string $candidate Path to resolve. * @param string $basedir_real Already-resolved (realpath) basedir. * @return string|null */ private static function contain_under_basedir( string $candidate, string $basedir_real ): ?string { $resolved = \realpath( $candidate ); if ( false === $resolved ) { return null; } $prefix = \rtrim( $basedir_real, \DIRECTORY_SEPARATOR ) . \DIRECTORY_SEPARATOR; if ( 0 !== \strpos( $resolved, $prefix ) ) { return null; } return \is_readable( $resolved ) ? $resolved : null; } /** * `wp_generate_attachment_metadata` filter callback. Schedules * a single-event cron run to compute the blurhash for image * attachments. The filter return value is the metadata unchanged * — we use the hook purely as an "image is ready" notifier. * * @param array $metadata Attachment metadata as built by WP. * @param int $attachment_id Attachment post ID. * @return array */ public static function schedule_encode( $metadata, $attachment_id ) { $attachment_id = (int) $attachment_id; if ( $attachment_id < 1 || ! self::is_encodable_attachment( $attachment_id ) ) { return $metadata; } // Skip both the invalidation AND the cron enqueue when the // encoder can't actually run on this host. Without the gate, // a metadata regen on a GD-less site would wipe a previously // stored hash (computed on a different host, restored from // backup, etc.) and queue a cron event guaranteed to fail. if ( ! self::is_encoder_runnable() ) { return $metadata; } // Invalidate any prior hash so cron will re-encode against // the latest bytes. `wp_generate_attachment_metadata` fires // on initial upload AND on media-replace / crop / regen, so // without this delete a replaced image would keep federating // the placeholder for its prior bytes. self::delete( $attachment_id ); self::schedule( $attachment_id ); return $metadata; } /** * Whether an attachment is something the encoder will attempt on this host. * True for `wp_attachment_is_image` attachments whose mime is in * {@see self::ENCODABLE_MIME_TYPES} AND that this GD build can actually * decode; false for SVG, non-image media, deleted/nonexistent IDs, and * formats this GD build lacks support for (e.g. WebP/AVIF/BMP on a * stripped-down GD). Shared gate used by the upload-scheduling path, the * CLI backfill (to count "we don't encode this" as a skip rather than a * failure), and the encoder itself. * * The GD-capability check matters because `schedule_encode()` invalidates * any prior hash before queueing the cron encode: without it, a metadata * regen on a host that can't decode the format would wipe a previously * good hash (e.g. migrated from a host with broader GD support) and queue * a cron event guaranteed to fail. * * @param int $attachment_id Attachment post ID. * @return bool */ public static function is_encodable_attachment( int $attachment_id ): bool { if ( $attachment_id < 1 || ! \wp_attachment_is_image( $attachment_id ) ) { return false; } $mime = \get_post_mime_type( $attachment_id ); return \is_string( $mime ) && \in_array( $mime, self::ENCODABLE_MIME_TYPES, true ) && self::host_can_decode( $mime ); } /** * Whether this GD build can decode the given image mime type. * * GD support for WebP, AVIF, and BMP is build-dependent, so a mime being * in {@see self::ENCODABLE_MIME_TYPES} is necessary but not sufficient. * `imagetypes()` reports what the running GD can actually handle. The * `IMG_*` flags for WebP/AVIF/BMP are not defined on every PHP version * (`IMG_AVIF` is PHP 8.1+), so guard each with `defined()`. * * @param string $mime The attachment mime type. * @return bool */ private static function host_can_decode( $mime ) { if ( ! \function_exists( 'imagetypes' ) ) { return false; } $supported = \imagetypes(); switch ( $mime ) { case 'image/jpeg': return (bool) ( $supported & IMG_JPG ); case 'image/png': return (bool) ( $supported & IMG_PNG ); case 'image/gif': return (bool) ( $supported & IMG_GIF ); case 'image/webp': return \defined( 'IMG_WEBP' ) && (bool) ( $supported & IMG_WEBP ); case 'image/avif': return \defined( 'IMG_AVIF' ) && (bool) ( $supported & IMG_AVIF ); case 'image/bmp': return \defined( 'IMG_BMP' ) && (bool) ( $supported & IMG_BMP ); default: return false; } } /** * Whether the host has the GD primitives the encoder needs to * actually run. Public so the WP-CLI backfill can fail-fast with * one clear error message rather than emit a warning per * attachment, and so the upload/cron paths can short-circuit * without leaving cron noise behind on a GD-less host. * * @return bool */ public static function is_encoder_runnable(): bool { return \function_exists( 'imagecreatefromstring' ) && \function_exists( 'imagecreatetruecolor' ) && \function_exists( 'imagescale' ); } /** * Queue (or skip, if already queued) a cron event to compute * the blurhash for an attachment. Internal helper for * {@see self::schedule_encode()} — callers should go through * that filter callback so the metadata-regen invalidation pass * stays load-bearing. * * @param int $attachment_id Attachment post ID. */ private static function schedule( int $attachment_id ): void { if ( $attachment_id < 1 ) { return; } /* * Rely on WP's own duplicate-event guard inside * `wp_schedule_single_event` (rejects matching args within * the 10-minute window) rather than running an explicit * `wp_next_scheduled` check first. The explicit check did * nothing the underlying scheduler doesn't already do and * added a needless read against the autoloaded cron option * on every attachment metadata regen. * * Defer one minute rather than firing at `time()`. This * callback runs inside the `wp_generate_attachment_metadata` * filter, BEFORE `wp_update_attachment_metadata()` commits the * sizes array. A concurrent wp-cron tick could otherwise run * `run_encode()` before that commit lands, find no thumbnail * intermediate yet, and fall back to encoding the full-size * original. The one-minute delay lets the metadata write * settle first; single-event dedup semantics are unchanged. */ \wp_schedule_single_event( \time() + MINUTE_IN_SECONDS, self::CRON_HOOK, array( $attachment_id ) ); } /** * Cron callback: compute and store the blurhash. No-op when a * hash is already stored; callers wanting a re-encode delete * the postmeta first ({@see self::delete()}) — that's what * {@see self::schedule_encode()} does before scheduling, so the * media-replace path always recomputes. * * Emits `activitypub_blurhash_encode_failed` (action) and an * `error_log` line when the encoder returns null without a * pre-existing stored hash, so transient failures (NFS hiccup, * S3 lag, GD blip) leave a signal for monitoring instead of * silently never producing a placeholder. * * @param int $attachment_id Attachment post ID. */ public static function run_encode( int $attachment_id ): void { $attachment_id = (int) $attachment_id; if ( $attachment_id < 1 ) { return; } // Predictable unencodability (system-wide GD missing, // non-raster mime, deleted attachment) is silent — logging // per-event would spam diagnostics on every scheduled run // even though the reason is global. Only unexpected failures // (encoder exception, missing file, decode failure) below // fire the diagnostic action. if ( ! self::is_encoder_runnable() || ! self::is_encodable_attachment( $attachment_id ) ) { return; } if ( null !== self::get( $attachment_id ) ) { return; } $hash = self::encode_from_attachment( $attachment_id ); if ( \is_string( $hash ) ) { self::set( $attachment_id, $hash ); return; } /* * `false` is a policy skip (source over the decode-bomb * dimension cap): deliberate, deterministic, and global to * the source bytes — logging it would re-introduce the * per-run noise this bucket exists to avoid. Only `null` * (unexpected failure) falls through to diagnostics. */ if ( false === $hash ) { return; } // Silent-failure guard — surface the gap so an operator can // investigate (or wire monitoring against the action). // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- intentional plugin diagnostics; cron path only. \error_log( "[activitypub:blurhash] encode failed for attachment {$attachment_id}; placeholder will be absent until backfill." ); /** * Fires when the cron-deferred encoder fails to produce a * hash for an attachment. Monitoring integrations can hook * this to count blurhash-encode failures over time. * * @param int $attachment_id The attachment ID that failed to encode. */ \do_action( 'activitypub_blurhash_encode_failed', $attachment_id ); } /** * `activitypub_attachment` filter callback. Injects `blurhash` * into image attachment arrays when a usable postmeta value is * stored. No-op on anything else (non-image attachments, missing * meta, malformed meta, malformed arrays) so non-photo federation * paths are untouched. Sanitization is enforced inside * {@see self::get()} — anyone with `edit_post_meta` on an * attachment could otherwise rewrite `_activitypub_blurhash` to bytes * that break `wp_json_encode` and drop the entire AP envelope. * * @param mixed $attachment The attachment array as built by bundled AP. * @param mixed $attachment_id The attachment post ID (mixed because the upstream filter is loosely typed). * @return mixed */ public static function inject_blurhash( $attachment, $attachment_id ) { if ( ! \is_array( $attachment ) ) { return $attachment; } if ( 'Image' !== ( $attachment['type'] ?? '' ) ) { return $attachment; } $hash = self::get( (int) $attachment_id ); if ( null === $hash ) { return $attachment; } $attachment['blurhash'] = $hash; return $attachment; } /** * Validate a stored hash against the blurhash spec's character * set and our length bound. Treat any out-of-bounds value as * absent rather than coerce — preserves the "no blurhash is * better than a wrong blurhash" posture the rest of the class * follows. The base83 alphabet is defined by the spec at * {@link https://github.com/woltapp/blurhash/blob/master/Algorithm.md#base-83}. * * @param string $hash Candidate hash string. * @return bool */ private static function is_well_formed_hash( string $hash ): bool { $length = \strlen( $hash ); if ( $length < 6 || $length > self::MAX_HASH_LENGTH ) { return false; } // Base83 alphabet — same character set the encoder library // emits, conservative enough to reject any byte sequence // that would surprise a downstream JSON encoder or client // decoder. return 1 === \preg_match( '/\A[0-9A-Za-z#$%*+,\-.:;=?@\[\]\^_{|}~]+\z/', $hash ); } }