| 1 |
<?php |
| 2 |
|
| 3 |
namespace YayMail\SocialIcons; |
| 4 |
|
| 5 |
use YayMail\Utils\SingletonTrait; |
| 6 |
|
| 7 |
/** |
| 8 |
* Serves dynamically tinted social icon PNGs for the Custom theme. |
| 9 |
* |
| 10 |
* Query params: |
| 11 |
* - action: yaymail_social_icon |
| 12 |
* - icon: social slug (whitelist) |
| 13 |
* - color: hex without # (3 or 6 chars) |
| 14 |
* - size: output size in px (optional) |
| 15 |
*/ |
| 16 |
class SocialIconEndpoint { |
| 17 |
|
| 18 |
use SingletonTrait; |
| 19 |
|
| 20 |
private const ACTION = 'yaymail_social_icon'; |
| 21 |
|
| 22 |
private const ICONS = [ |
| 23 |
'behance', |
| 24 |
'discord', |
| 25 |
'dribble', |
| 26 |
'facebook', |
| 27 |
'github', |
| 28 |
'google', |
| 29 |
'instagram', |
| 30 |
'linkedin', |
| 31 |
'medium', |
| 32 |
'messenger', |
| 33 |
'pinterest', |
| 34 |
'reddit', |
| 35 |
'skype', |
| 36 |
'snapchat', |
| 37 |
'spotify', |
| 38 |
'telegram', |
| 39 |
'tiktok', |
| 40 |
'twitch', |
| 41 |
'twitter', |
| 42 |
'viber', |
| 43 |
'vimeo', |
| 44 |
'website', |
| 45 |
'wechat', |
| 46 |
'whatsapp', |
| 47 |
'youtube', |
| 48 |
'zillow', |
| 49 |
]; |
| 50 |
|
| 51 |
public function __construct() { |
| 52 |
$action = isset( $_GET['action'] ) ? sanitize_text_field( wp_unslash( $_GET['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 53 |
|
| 54 |
if ( self::ACTION !== $action ) { |
| 55 |
return; |
| 56 |
} |
| 57 |
|
| 58 |
$this->serve(); |
| 59 |
} |
| 60 |
|
| 61 |
/** |
| 62 |
* Build a public URL for a tinted social icon. |
| 63 |
* |
| 64 |
* @param string $icon Social slug. |
| 65 |
* @param string $color Hex color with or without #. |
| 66 |
* @param int $size Output size in px. |
| 67 |
*/ |
| 68 |
public static function get_url( $icon, $color, $size = 64 ) { |
| 69 |
$color = strtolower( ltrim( (string) $color, '#' ) ); |
| 70 |
// Extra safety: strip any non-hex leftovers if a bad value slips through. |
| 71 |
$color = preg_replace( '/[^0-9a-f]/', '', $color ); |
| 72 |
if ( 3 !== strlen( $color ) && 6 !== strlen( $color ) ) { |
| 73 |
$color = '333333'; |
| 74 |
} |
| 75 |
$size = max( 16, min( 256, absint( $size ) ) ); |
| 76 |
|
| 77 |
return add_query_arg( |
| 78 |
[ |
| 79 |
'action' => self::ACTION, |
| 80 |
'icon' => sanitize_key( $icon ), |
| 81 |
'color' => $color, |
| 82 |
'size' => $size, |
| 83 |
], |
| 84 |
home_url( '/' ) |
| 85 |
); |
| 86 |
} |
| 87 |
|
| 88 |
private function serve() { |
| 89 |
if ( ! function_exists( 'imagecreatefrompng' ) || ! function_exists( 'imagepng' ) ) { |
| 90 |
status_header( 503 ); |
| 91 |
exit; |
| 92 |
} |
| 93 |
|
| 94 |
$icon = isset( $_GET['icon'] ) ? sanitize_key( wp_unslash( $_GET['icon'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 95 |
$color = isset( $_GET['color'] ) ? sanitize_text_field( wp_unslash( $_GET['color'] ) ) : '333333'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 96 |
$size = isset( $_GET['size'] ) ? absint( wp_unslash( $_GET['size'] ) ) : 64; // phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 97 |
|
| 98 |
$color = ltrim( $color, '#' ); |
| 99 |
if ( ! in_array( $icon, self::ICONS, true ) || ! preg_match( '/^[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/', $color ) ) { |
| 100 |
status_header( 404 ); |
| 101 |
exit; |
| 102 |
} |
| 103 |
|
| 104 |
if ( 3 === strlen( $color ) ) { |
| 105 |
$color = $color[0] . $color[0] . $color[1] . $color[1] . $color[2] . $color[2]; |
| 106 |
} |
| 107 |
$color = strtolower( $color ); |
| 108 |
$size = max( 16, min( 256, $size ) ); |
| 109 |
|
| 110 |
$mask_path = YAYMAIL_PLUGIN_PATH . 'assets/images/social-icons/' . $icon . '/custom.png'; |
| 111 |
if ( ! file_exists( $mask_path ) ) { |
| 112 |
status_header( 404 ); |
| 113 |
exit; |
| 114 |
} |
| 115 |
|
| 116 |
$cache_key = md5( $icon . '-' . $color . '-' . $size . '-' . (string) filemtime( $mask_path ) ); |
| 117 |
$etag = '"' . $cache_key . '"'; |
| 118 |
if ( isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) && trim( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) === $etag ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput |
| 119 |
status_header( 304 ); |
| 120 |
exit; |
| 121 |
} |
| 122 |
|
| 123 |
$this->send_image_headers( $etag ); |
| 124 |
|
| 125 |
// Disk cache: (icon, color, size, mask-mtime) always renders identical |
| 126 |
// bytes, so once generated once on this server, skip GD entirely and |
| 127 |
// stream the cached file straight from disk. |
| 128 |
$cache_path = SocialIconImageCache::get_path( $cache_key ); |
| 129 |
if ( $cache_path && file_exists( $cache_path ) ) { |
| 130 |
readfile( $cache_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_put_contents |
| 131 |
exit; |
| 132 |
} |
| 133 |
|
| 134 |
$rgb = [ |
| 135 |
hexdec( substr( $color, 0, 2 ) ), |
| 136 |
hexdec( substr( $color, 2, 2 ) ), |
| 137 |
hexdec( substr( $color, 4, 2 ) ), |
| 138 |
]; |
| 139 |
|
| 140 |
$mask = imagecreatefrompng( $mask_path ); |
| 141 |
if ( false === $mask ) { |
| 142 |
status_header( 500 ); |
| 143 |
exit; |
| 144 |
} |
| 145 |
|
| 146 |
imagealphablending( $mask, false ); |
| 147 |
imagesavealpha( $mask, true ); |
| 148 |
|
| 149 |
$src_w = imagesx( $mask ); |
| 150 |
$src_h = imagesy( $mask ); |
| 151 |
|
| 152 |
// Source masks are solid white icons whose shape lives entirely in the |
| 153 |
// alpha channel, so a single native colorize call recolors every opaque |
| 154 |
// pixel to the target color (255 + offset = target) while leaving the |
| 155 |
// alpha untouched — equivalent to, but far faster than, a per-pixel loop. |
| 156 |
imagefilter( $mask, IMG_FILTER_COLORIZE, $rgb[0] - 255, $rgb[1] - 255, $rgb[2] - 255 ); |
| 157 |
|
| 158 |
$out = imagecreatetruecolor( $size, $size ); |
| 159 |
imagealphablending( $out, false ); |
| 160 |
imagesavealpha( $out, true ); |
| 161 |
$transparent = imagecolorallocatealpha( $out, 0, 0, 0, 127 ); |
| 162 |
imagefilledrectangle( $out, 0, 0, $size, $size, $transparent ); |
| 163 |
|
| 164 |
imagecopyresampled( $out, $mask, 0, 0, 0, 0, $size, $size, $src_w, $src_h ); |
| 165 |
|
| 166 |
imagedestroy( $mask ); |
| 167 |
|
| 168 |
// Capture the PNG bytes once so they can both be cached to disk and |
| 169 |
// streamed back, instead of calling imagepng() twice. |
| 170 |
ob_start(); |
| 171 |
imagepng( $out ); |
| 172 |
$png_data = ob_get_clean(); |
| 173 |
imagedestroy( $out ); |
| 174 |
|
| 175 |
if ( $cache_path ) { |
| 176 |
SocialIconImageCache::write( $cache_path, $png_data ); |
| 177 |
} |
| 178 |
|
| 179 |
echo $png_data; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped |
| 180 |
exit; |
| 181 |
} |
| 182 |
|
| 183 |
private function send_image_headers( $etag ) { |
| 184 |
header( 'Content-Type: image/png' ); |
| 185 |
header( 'Cache-Control: public, max-age=31536000, immutable' ); |
| 186 |
header( 'ETag: ' . $etag ); |
| 187 |
header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', time() + YEAR_IN_SECONDS ) . ' GMT' ); |
| 188 |
} |
| 189 |
} |
| 190 |
|