| @@ -163,9 +163,9 @@ | ||
| 163 | 163 | private function embed_local_images( string $html ): string { |
| 164 | 164 | return (string) preg_replace_callback( |
| 165 | 165 | '/(<img\b[^>]*\bsrc\s*=\s*["\'])([^"\']+)(["\'][^>]*>)/i', |
| 166 | 166 | function ( array $matches ): string { |
| 167 | - $data_uri = $this->image_src_to_data_uri( html_entity_decode( $matches[2], ENT_QUOTES, 'UTF-8' ) ); | |
| 167 | + $data_uri = ( new Local_Image_Resolver() )->data_uri( html_entity_decode( $matches[2], ENT_QUOTES, 'UTF-8' ) ); | |
| 168 | 168 | if ( null === $data_uri ) { |
| 169 | 169 | return $matches[0]; |
| 170 | 170 | } |
| 171 | 171 | |
| @@ -172,183 +172,8 @@ | ||
| 172 | 172 | return $matches[1] . esc_attr( $data_uri ) . $matches[3]; |
| 173 | 173 | }, |
| 174 | 174 | $html |
| 175 | 175 | ); |
| 176 | - } | |
| 177 | - | |
| 178 | - /** | |
| 179 | - * Convert a local image source to a data URI. | |
| 180 | - * | |
| 181 | - * @param string $src Image source. | |
| 182 | - * | |
| 183 | - * @return string|null Data URI, or null when the source is not embeddable. | |
| 184 | - */ | |
| 185 | - private function image_src_to_data_uri( string $src ): ?string { | |
| 186 | - if ( '' === $src || 0 === strpos( $src, 'data:' ) ) { | |
| 187 | - return null; | |
| 188 | - } | |
| 189 | - | |
| 190 | - $path = $this->local_image_path_from_src( $src ); | |
| 191 | - if ( null === $path || ! is_readable( $path ) || ! is_file( $path ) ) { | |
| 192 | - return null; | |
| 193 | - } | |
| 194 | - | |
| 195 | - $bytes = file_get_contents( $path ); | |
| 196 | - if ( false === $bytes || '' === $bytes ) { | |
| 197 | - return null; | |
| 198 | - } | |
| 199 | - | |
| 200 | - $mime = $this->image_mime_type( $path ); | |
| 201 | - if ( null === $mime ) { | |
| 202 | - return null; | |
| 203 | - } | |
| 204 | - | |
| 205 | - return 'data:' . $mime . ';base64,' . base64_encode( $bytes ); | |
| 206 | - } | |
| 207 | - | |
| 208 | - /** | |
| 209 | - * Resolve an image src to a safe local filesystem path. | |
| 210 | - * | |
| 211 | - * @param string $src Image source. | |
| 212 | - * | |
| 213 | - * @return string|null Local path, or null when the src is external/unknown. | |
| 214 | - */ | |
| 215 | - private function local_image_path_from_src( string $src ): ?string { | |
| 216 | - $src = trim( $src ); | |
| 217 | - $src = explode( '#', $src, 2 )[0]; | |
| 218 | - $src = explode( '?', $src, 2 )[0]; | |
| 219 | - | |
| 220 | - if ( 0 === strpos( $src, '/' ) && 0 !== strpos( $src, '//' ) && \defined( 'ABSPATH' ) ) { | |
| 221 | - $path = wp_normalize_path( ABSPATH . ltrim( $src, '/' ) ); | |
| 222 | - return $this->is_allowed_local_image_path( $path ) ? $path : null; | |
| 223 | - } | |
| 224 | - | |
| 225 | - $mappings = $this->local_url_path_mappings(); | |
| 226 | - foreach ( $mappings as $url_base => $path_base ) { | |
| 227 | - if ( 0 !== strpos( $src, $url_base ) ) { | |
| 228 | - continue; | |
| 229 | - } | |
| 230 | - | |
| 231 | - $relative = ltrim( substr( $src, \strlen( $url_base ) ), '/\\' ); | |
| 232 | - $path = wp_normalize_path( trailingslashit( $path_base ) . $relative ); | |
| 233 | - | |
| 234 | - return $this->is_allowed_local_image_path( $path ) ? $path : null; | |
| 235 | - } | |
| 236 | - | |
| 237 | - return null; | |
| 238 | - } | |
| 239 | - | |
| 240 | - /** | |
| 241 | - * Build URL-to-path mappings for local WordPress assets. | |
| 242 | - * | |
| 243 | - * @return array<string,string> | |
| 244 | - */ | |
| 245 | - private function local_url_path_mappings(): array { | |
| 246 | - $uploads = wp_upload_dir(); | |
| 247 | - $plugin = dirname( __DIR__, 2 ); | |
| 248 | - | |
| 249 | - $mappings = array( | |
| 250 | - $uploads['baseurl'] => $uploads['basedir'], | |
| 251 | - content_url() => \defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR : '', | |
| 252 | - plugins_url( '', $plugin . '/woocommerce-pos.php' ) => $plugin, | |
| 253 | - ); | |
| 254 | - | |
| 255 | - $normalized = array(); | |
| 256 | - foreach ( $mappings as $url => $path ) { | |
| 257 | - if ( '' === $url || '' === $path ) { | |
| 258 | - continue; | |
| 259 | - } | |
| 260 | - | |
| 261 | - $normalized[ trailingslashit( $url ) ] = wp_normalize_path( $path ); | |
| 262 | - } | |
| 263 | - | |
| 264 | - uksort( | |
| 265 | - $normalized, | |
| 266 | - static function ( string $a, string $b ): int { | |
| 267 | - return \strlen( $b ) <=> \strlen( $a ); | |
| 268 | - } | |
| 269 | - ); | |
| 270 | - | |
| 271 | - return $normalized; | |
| 272 | - } | |
| 273 | - | |
| 274 | - /** | |
| 275 | - * Check that a resolved path stays within known local asset roots. | |
| 276 | - * | |
| 277 | - * @param string $path Resolved path. | |
| 278 | - * | |
| 279 | - * @return bool | |
| 280 | - */ | |
| 281 | - private function is_allowed_local_image_path( string $path ): bool { | |
| 282 | - $real_path = realpath( $path ); | |
| 283 | - if ( false === $real_path ) { | |
| 284 | - return false; | |
| 285 | - } | |
| 286 | - | |
| 287 | - $real_path = wp_normalize_path( $real_path ); | |
| 288 | - foreach ( $this->allowed_local_image_roots() as $root ) { | |
| 289 | - if ( 0 === strpos( $real_path, trailingslashit( $root ) ) || $real_path === $root ) { | |
| 290 | - return true; | |
| 291 | - } | |
| 292 | - } | |
| 293 | - | |
| 294 | - return false; | |
| 295 | - } | |
| 296 | - | |
| 297 | - /** | |
| 298 | - * Allowed local image roots. | |
| 299 | - * | |
| 300 | - * @return string[] | |
| 301 | - */ | |
| 302 | - private function allowed_local_image_roots(): array { | |
| 303 | - $uploads = wp_upload_dir(); | |
| 304 | - $roots = array( | |
| 305 | - $uploads['basedir'], | |
| 306 | - \defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR : '', | |
| 307 | - dirname( __DIR__, 2 ), | |
| 308 | - ); | |
| 309 | - | |
| 310 | - return array_values( | |
| 311 | - array_filter( | |
| 312 | - array_map( | |
| 313 | - static function ( string $root ): string { | |
| 314 | - $real = realpath( $root ); | |
| 315 | - return false === $real ? '' : wp_normalize_path( $real ); | |
| 316 | - }, | |
| 317 | - $roots | |
| 318 | - ) | |
| 319 | - ) | |
| 320 | - ); | |
| 321 | - } | |
| 322 | - | |
| 323 | - /** | |
| 324 | - * Determine a supported image MIME type from path. | |
| 325 | - * | |
| 326 | - * @param string $path Local image path. | |
| 327 | - * | |
| 328 | - * @return string|null MIME type. | |
| 329 | - */ | |
| 330 | - private function image_mime_type( string $path ): ?string { | |
| 331 | - $type = wp_check_filetype( $path ); | |
| 332 | - $mime = false !== $type['type'] ? (string) $type['type'] : ''; | |
| 333 | - | |
| 334 | - if ( '' === $mime ) { | |
| 335 | - $extension = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ); | |
| 336 | - $mime = array( | |
| 337 | - 'gif' => 'image/gif', | |
| 338 | - 'jpg' => 'image/jpeg', | |
| 339 | - 'jpeg' => 'image/jpeg', | |
| 340 | - 'png' => 'image/png', | |
| 341 | - 'svg' => 'image/svg+xml', | |
| 342 | - 'webp' => 'image/webp', | |
| 343 | - )[ $extension ] ?? ''; | |
| 344 | - } | |
| 345 | - | |
| 346 | - if ( 0 !== strpos( $mime, 'image/' ) ) { | |
| 347 | - return null; | |
| 348 | - } | |
| 349 | - | |
| 350 | - return $mime; | |
| 351 | 176 | } |
| 352 | 177 | |
| 353 | 178 | /** |
| 354 | 179 | * Build and render a Dompdf instance. |