\d+)',
array(
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => array( $this, 'share_image' ),
'args' => array(
'station' => array(
'sanitize_callback' => 'absint',
'validate_callback' => static function ( $value ) {
return is_numeric( $value ) && (int) $value > 0;
},
),
),
)
);
}
/**
* Open Graph tags for a station listen page.
*
* @param int $station_id Station.
*/
public function og_tags( $station_id ) {
// The listen template passes its whole var bag; accept either form.
if ( is_array( $station_id ) ) {
$station_id = isset( $station_id['station']['id'] ) ? $station_id['station']['id'] : ( isset( $station_id['config']['station'] ) ? $station_id['config']['station'] : 0 );
}
$station = Station::to_public( (int) $station_id );
if ( ! $station ) {
return;
}
$np = \StreamSera\Metadata\Resolver::now_playing( (int) $station_id, false, true );
$title = $station['name'];
$desc = $station['slogan'] ? $station['slogan'] : $station['description'];
if ( ! empty( $np['title'] ) ) {
/* translators: 1: track, 2: station */
$desc = sprintf( __( 'Now playing: %1$s on %2$s', 'streamsera' ), implode( ' – ', array_filter( array( trim( (string) ( $np['artist'] ?? '' ) ), trim( (string) $np['title'] ) ) ) ), $station['name'] );
}
$image = self::share_image_url( $station_id );
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
}
/**
* Render the share image.
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function share_image( WP_REST_Request $request ) {
$station_id = absint( streamsera_url_param( $request, 'station' ) );
$station = Station::to_public( $station_id );
if ( ! $station ) {
return new WP_Error( 'streamsera_not_found', __( 'Station not found.', 'streamsera' ), array( 'status' => 404 ) );
}
if ( ! function_exists( 'imagecreatetruecolor' ) ) {
// No GD: redirect to the logo (or nothing).
if ( $station['logo'] ) {
// Logos are attachments: same host as the site, or the uploads host when media is offloaded.
$uploads = wp_get_upload_dir();
$upload_host = ! empty( $uploads['baseurl'] ) ? (string) wp_parse_url( $uploads['baseurl'], PHP_URL_HOST ) : '';
$logo_host = (string) wp_parse_url( $station['logo'], PHP_URL_HOST );
$allow = static function ( $hosts ) use ( $upload_host ) {
$hosts[] = $upload_host;
return $hosts;
};
if ( $upload_host && $logo_host && strtolower( $logo_host ) === strtolower( $upload_host ) ) {
add_filter( 'allowed_redirect_hosts', $allow );
}
$target = wp_validate_redirect( $station['logo'], '' );
if ( $target ) {
wp_safe_redirect( $target, 302 );
exit;
}
remove_filter( 'allowed_redirect_hosts', $allow );
}
return new WP_Error( 'streamsera_no_gd', __( 'Image library unavailable.', 'streamsera' ), array( 'status' => 501 ) );
}
$np = \StreamSera\Metadata\Resolver::now_playing( $station_id, false, true );
$track = ! empty( $np['title'] ) ? implode( ' – ', array_filter( array( trim( (string) ( $np['artist'] ?? '' ) ), trim( (string) $np['title'] ) ) ) ) : '';
// One cache entry per station (replaced when the track changes), so the options table never
// collects an image per song. Binary PNG bytes are stored hex-encoded for the text column.
$cache_key = 'streamsera_share_' . $station_id;
$track_key = md5( $track );
$cached = get_transient( $cache_key );
$png = is_array( $cached ) && isset( $cached['track'], $cached['png'] ) && $track_key === $cached['track'] && is_string( $cached['png'] ) && ctype_xdigit( $cached['png'] ) ? hex2bin( $cached['png'] ) : false;
if ( ! $png ) {
$png = self::render( $station, $track );
set_transient(
$cache_key,
array(
'track' => $track_key,
'png' => bin2hex( $png ),
),
MINUTE_IN_SECONDS
);
}
// Send the image through GD (no raw echo of bytes): decode the cached PNG and let imagepng() write it.
$image = $png ? imagecreatefromstring( $png ) : false;
if ( ! $image ) {
return new WP_Error( 'streamsera_share_image', __( 'Could not create the image.', 'streamsera' ), array( 'status' => 500 ) );
}
header( 'Content-Type: image/png' );
header( 'Cache-Control: public, max-age=60' );
imagesavealpha( $image, true );
imagepng( $image );
imagedestroy( $image );
exit;
}
/**
* Build the PNG bytes.
*
* @param array $station Public station.
* @param string $track Track text.
* @return string
*/
private static function render( $station, $track ) {
$w = 1200;
$h = 630;
$img = imagecreatetruecolor( $w, $h );
// Background gradient (dark navy → indigo).
for ( $y = 0; $y < $h; $y++ ) {
$t = $y / $h;
$c = imagecolorallocate( $img, (int) ( 15 + 40 * $t ), (int) ( 17 + 20 * $t ), (int) ( 23 + 70 * $t ) );
imageline( $img, 0, $y, $w, $y, $c );
}
$accent = imagecolorallocate( $img, 99, 102, 241 );
$white = imagecolorallocate( $img, 255, 255, 255 );
$muted = imagecolorallocate( $img, 190, 195, 215 );
imagefilledrectangle( $img, 0, $h - 14, $w, $h, $accent );
// Logo.
$text_x = 90;
if ( ! empty( $station['logo'] ) ) {
$logo = self::load_logo( $station['logo'] );
if ( $logo ) {
$size = 260;
$lw = imagesx( $logo );
$lh = imagesy( $logo );
$dst = imagecreatetruecolor( $size, $size );
imagealphablending( $dst, false );
imagesavealpha( $dst, true );
imagefill( $dst, 0, 0, imagecolorallocatealpha( $dst, 0, 0, 0, 127 ) );
imagecopyresampled( $dst, $logo, 0, 0, 0, 0, $size, $size, $lw, $lh );
imagecopy( $img, $dst, 90, ( $h - $size ) / 2, 0, 0, $size, $size );
imagedestroy( $dst );
imagedestroy( $logo );
$text_x = 90 + $size + 60;
}
}
$font = self::font();
$name = $station['name'];
$lead = $track ? __( "I'm listening to", 'streamsera' ) : __( 'Listen live', 'streamsera' );
if ( $font ) {
imagettftext( $img, 28, 0, $text_x, 220, $muted, $font, $lead );
self::wrapped( $img, $font, 56, $text_x, 300, $white, $track ? $track : $name, $w - $text_x - 80, 2 );
if ( $track ) {
/* translators: %s: station name */
imagettftext( $img, 30, 0, $text_x, 470, $accent, $font, sprintf( __( 'on %s', 'streamsera' ), $name ) );
}
imagettftext( $img, 20, 0, $text_x, 560, $muted, $font, preg_replace( '#^https?://#', '', home_url() ) );
} else {
imagestring( $img, 5, $text_x, 200, $lead, $muted );
imagestring( $img, 5, $text_x, 260, $track ? $track : $name, $white );
if ( $track ) {
/* translators: %s: station name */
imagestring( $img, 5, $text_x, 320, sprintf( __( 'on %s', 'streamsera' ), $name ), $accent );
}
}
ob_start();
imagepng( $img, null, 6 );
imagedestroy( $img );
return (string) ob_get_clean();
}
/**
* Draw wrapped text.
*
* @param resource|\GdImage $img Image.
* @param string $font TTF font path.
* @param int $size Font size.
* @param int $x Left.
* @param int $y Baseline of the first line.
* @param int $color Allocated colour.
* @param string $text Text.
* @param int $max_w Max line width in px.
* @param int $max_lines Max lines before ellipsis.
*/
private static function wrapped( $img, $font, $size, $x, $y, $color, $text, $max_w, $max_lines ) {
$words = preg_split( '/\s+/', (string) $text );
$lines = array();
$line = '';
foreach ( $words as $word ) {
$try = trim( $line . ' ' . $word );
$box = imagettfbbox( $size, 0, $font, $try );
if ( $box[2] - $box[0] > $max_w && $line ) {
$lines[] = $line;
$line = $word;
if ( count( $lines ) >= $max_lines ) {
break;
}
} else {
$line = $try;
}
}
if ( $line && count( $lines ) < $max_lines ) {
$lines[] = $line;
} elseif ( count( $lines ) >= $max_lines ) {
$lines[ $max_lines - 1 ] = rtrim( $lines[ $max_lines - 1 ], '.' ) . '…';
}
foreach ( $lines as $i => $l ) {
imagettftext( $img, $size, 0, $x, $y + $i * ( $size * 1.5 ), $color, $font, $l );
}
}
/**
* TTF font for share images, if a site provides one; null = GD's built-in bitmap font.
*
* @return string|null
*/
private static function font() {
if ( ! function_exists( 'imagettftext' ) ) {
return null;
}
/**
* Path to a TrueType (.ttf) font used for the share image text.
*
* Empty by default: the image then uses GD's built-in font. Point it at a .ttf file on the
* server (e.g. one shipped with your theme) for nicer typography.
*
* @param string $path Absolute path to a .ttf file, or ''.
*/
$path = (string) apply_filters( 'streamsera_share_image_font', '' );
if ( '' === $path || ! preg_match( '/\.ttf$/i', $path ) || ! is_file( $path ) || ! is_readable( $path ) ) {
return null;
}
return $path;
}
/**
* Load the station logo as a GD image (local uploads only).
*
* @param string $url Logo URL.
* @return resource|\GdImage|null
*/
private static function load_logo( $url ) {
$uploads = wp_get_upload_dir();
$baseurl = trailingslashit( (string) $uploads['baseurl'] );
if ( 0 !== strpos( (string) $url, $baseurl ) ) {
return null;
}
$relative = (string) wp_parse_url( substr( (string) $url, strlen( $baseurl ) ), PHP_URL_PATH );
$base = realpath( (string) $uploads['basedir'] );
$path = $base ? realpath( $base . '/' . rawurldecode( $relative ) ) : false;
// Resolved path must stay inside the uploads directory (no ../ or symlink escapes).
if ( ! $base || ! $path || 0 !== strpos( $path, trailingslashit( $base ) ) || ! is_file( $path ) || ! is_readable( $path ) ) {
return null;
}
// Open the file with GD's own loader for its type (no raw file read).
$size = wp_getimagesize( $path );
$mime = is_array( $size ) && ! empty( $size['mime'] ) ? (string) $size['mime'] : '';
$loaders = array(
'image/jpeg' => 'imagecreatefromjpeg',
'image/png' => 'imagecreatefrompng',
'image/gif' => 'imagecreatefromgif',
'image/webp' => 'imagecreatefromwebp',
);
if ( ! isset( $loaders[ $mime ] ) || ! function_exists( $loaders[ $mime ] ) ) {
return null;
}
$gd = call_user_func( $loaders[ $mime ], $path );
return $gd ? $gd : null;
}
}