updated plugin ActivityPub version 9.1.0
This commit is contained in:
745
wp-content/plugins/activitypub/includes/class-blurhash.php
Normal file
745
wp-content/plugins/activitypub/includes/class-blurhash.php
Normal file
@ -0,0 +1,745 @@
|
||||
<?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 4–5 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 );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user