\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; } }