Files
laipower/wp-content/plugins/activitypub/includes/class-blurhash.php

746 lines
28 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Blurhash encoder + storage for image attachments.
*
* @package Activitypub
*/
namespace Activitypub;
/**
* Compute, store, and inject Blurhash placeholder strings for image
* attachments so federated ActivityPub posts emit
* `attachment[].blurhash` — the colored-blur preview that Pixelfed,
* Mastodon, and other fediverse clients paint while the full image
* is still loading. Without it, federated WP photos sit on a grey
* placeholder where native uploads paint instantly; with it, the
* loading state matches native.
*
* Encoding runs at upload time via `wp_generate_attachment_metadata`,
* deferred to cron so the upload UI returns immediately. The hash
* is persisted as attachment postmeta ({@see self::META_KEY}) and
* read back by the `activitypub_attachment` projector — zero
* per-publish CPU cost, federation hot path stays cheap.
*
* Pure no-op when GD isn't available: the encoder needs an
* `[r,g,b][][]` pixel array, and GD's `imagecreatefrom*` family is
* the only host-portable way to build one. Sites running Imagick-only
* just don't get blurhash placeholders — attachments still federate,
* minus the field. Same posture for any other failure (unreadable
* file, encoder exception, deleted attachment): never blocks the
* upload, never blocks federation.
*
* Called from `activitypub.php` on `init`.
* Adapted from Automattic/FOSSE (https://github.com/Automattic/fosse).
*
* @since 9.0.0
*/
class Blurhash {
/**
* Postmeta key holding the encoded blurhash for an attachment.
*
* @var string
*/
public const META_KEY = '_activitypub_blurhash';
/**
* Cron hook fired for each attachment that needs a hash computed.
*
* @var string
*/
public const CRON_HOOK = 'activitypub_blurhash_compute';
/**
* DCT component count passed to the encoder as BOTH the X and
* Y dimensions — kept as a single number because the two
* dimensions are always equal in this encoder and splitting
* them implies a tuning surface that doesn't exist. Wolt's
* reference recommends 45 for landscape and lower for
* portrait; 4 is the middle ground Mastodon also defaults to.
* Hash length grows with the product, so 4×4 keeps the encoded
* string short.
*
* @var int
*/
private const COMPONENTS = 4;
/**
* Upper bound on stored blurhash string length, in characters.
* A 4×4 component grid produces a 30-character hash; the
* theoretical max for the 9×9 component grid the spec supports
* is 99 characters. We cap a little above that as a defense
* against postmeta poisoning — anyone with `edit_post_meta` on
* an attachment could otherwise write arbitrary bytes that we
* would then federate straight into the AP envelope.
*
* @var int
*/
private const MAX_HASH_LENGTH = 128;
/**
* Image size used as the encoder's source. Blurhash encoding is
* O(N) over pixel count and the output is a few low-frequency DCT
* coefficients — feeding it a 12-megapixel original would burn
* CPU producing a hash that's perceptually identical to the one
* computed off the ~150px thumbnail. WP's `thumbnail` size is the
* smallest variant guaranteed to exist for every uploaded image.
*
* @var string
*/
private const ENCODE_SIZE = 'thumbnail';
/**
* Hard upper bound on the longest edge fed to the encoder. Even
* when {@see self::resolve_encode_path()} returns the original
* (no intermediate, fallback path, misconfigured thumbnail size),
* the GD image is downscaled to this max edge before the per-pixel
* array is built. Keeps the PHP array allocation bounded — without
* this cap, a 4000×3000 original would build a 12M-cell nested
* array (~960 MB) and OOM the cron worker.
*
* @var int
*/
private const MAX_ENCODE_EDGE = 64;
/**
* Hard upper bound on the byte size of the source file fed into
* GD. Defends against pathological cases where {@see self::resolve_encode_path()}
* resolves to a huge original (or, via filterable
* `image_get_intermediate_size`, a path that's not actually an
* image at all). 8 MiB comfortably accommodates any realistic
* web-photo upload at full quality.
*
* @var int
*/
private const MAX_ENCODE_BYTES = 8388608;
/**
* Hard upper bound on the DECODED pixel count (width × height) of
* the source image. `imagecreatefromstring()` fully decodes the
* compressed bytes into an uncompressed GD bitmap BEFORE
* {@see self::encode_from_attachment()} downscales to
* {@see self::MAX_ENCODE_EDGE}, so a small, highly compressible
* source (e.g. a flat-color PNG declaring 30000×30000) slips past
* the {@see self::MAX_ENCODE_BYTES} byte cap yet forces a
* multi-gigabyte allocation — an uncatchable OOM that kills the
* cron worker or aborts a CLI backfill mid-run. We read the
* declared dimensions with `getimagesizefromstring()` first and
* skip (`encode_from_attachment()` returns `false`, which callers
* treat as "we don't encode this", not a failure) when the
* product exceeds this cap. 50 megapixels comfortably covers any
* realistic camera/phone upload while rejecting decompression bombs.
*
* @var int
*/
private const MAX_ENCODE_PIXELS = 50000000;
/**
* Raster MIME types GD can decode via `imagecreatefromstring`.
* Used as the early gate that splits "we don't encode this"
* (skip silently) from "encoder failed" (emit warning, count
* against exit status). SVG/XML, ICO, and other formats either
* GD can't read or aren't raster get filtered out before the
* encoder runs.
*
* @var array<int, string>
*/
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 );
}
}