PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 1.9.13 1.9.12 1.9.11 1.9.10 All 159 releases
woocommerce-pos / includes / Services / Local_Image_Resolver.php

Local_Image_Resolver.php in WCPOS – Point of Sale (POS) plugin for WooCommerce trunk, at includes/Services/Local_Image_Resolver.php

244 lines 6.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Local image resolution for receipt rendering.
4 *
5 * Receipt templates carry logos as ordinary WordPress URLs, but nothing that
6 * renders a receipt may fetch a URL: the PDF path runs with Dompdf's remote
7 * access disabled, and the cloud-print raster runs inside a printer's job fetch,
8 * where an outbound HTTP call would stall the print.
9 *
10 * So local URLs are resolved to bytes on disk instead. Resolution is deliberately
11 * narrow — only the uploads directory, wp-content and this plugin's own directory
12 * are reachable, and every candidate path is realpath()-checked against those
13 * roots so a crafted `../` src cannot read outside them.
14 *
15 * Extracted from Pdf_Renderer, which still uses it for Dompdf's data-URI
16 * embedding, so both renderers resolve images the same way.
17 *
18 * @package WCPOS\WooCommercePOS\Services
19 */
20
21 namespace WCPOS\WooCommercePOS\Services;
22
23 /**
24 * Local_Image_Resolver class.
25 */
26 class Local_Image_Resolver {
27
28 /**
29 * Read the bytes an image src points at.
30 *
31 * Accepts a data URI (decoded in place) or a local WordPress URL/path.
32 * Remote URLs resolve to '' — they are never fetched.
33 *
34 * @param string $src Image source.
35 *
36 * @return string The image bytes, or '' when the src is not resolvable.
37 */
38 public function bytes( string $src ): string {
39 $src = trim( $src );
40 if ( '' === $src ) {
41 return '';
42 }
43
44 if ( 1 === preg_match( '#^data:image/[a-z.+-]+;base64,#i', $src ) ) {
45 $decoded = base64_decode( (string) substr( $src, (int) strpos( $src, ',' ) + 1 ), true );
46
47 return false === $decoded ? '' : $decoded;
48 }
49
50 $path = $this->local_path( $src );
51 if ( null === $path ) {
52 return '';
53 }
54
55 $bytes = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file read, not HTTP.
56
57 return false === $bytes ? '' : $bytes;
58 }
59
60 /**
61 * Convert a local image source to a data URI.
62 *
63 * @param string $src Image source.
64 *
65 * @return string|null Data URI, or null when the source is not embeddable.
66 */
67 public function data_uri( string $src ): ?string {
68 if ( '' === $src || 0 === strpos( $src, 'data:' ) ) {
69 return null;
70 }
71
72 $path = $this->local_path( $src );
73 if ( null === $path ) {
74 return null;
75 }
76
77 $bytes = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file read, not HTTP.
78 if ( false === $bytes || '' === $bytes ) {
79 return null;
80 }
81
82 $mime = $this->mime_type( $path );
83 if ( null === $mime ) {
84 return null;
85 }
86
87 return 'data:' . $mime . ';base64,' . base64_encode( $bytes );
88 }
89
90 /**
91 * Resolve an image src to a readable local filesystem path.
92 *
93 * @param string $src Image source.
94 *
95 * @return string|null Local path, or null when the src is external/unknown.
96 */
97 public function local_path( string $src ): ?string {
98 $src = trim( $src );
99 $src = explode( '#', $src, 2 )[0];
100 $src = explode( '?', $src, 2 )[0];
101
102 if ( '' === $src ) {
103 return null;
104 }
105
106 $path = null;
107 if ( 0 === strpos( $src, '/' ) && 0 !== strpos( $src, '//' ) && \defined( 'ABSPATH' ) ) {
108 $path = wp_normalize_path( ABSPATH . ltrim( $src, '/' ) );
109 } else {
110 foreach ( $this->url_path_mappings() as $url_base => $path_base ) {
111 if ( 0 !== strpos( $src, $url_base ) ) {
112 continue;
113 }
114
115 $relative = ltrim( substr( $src, \strlen( $url_base ) ), '/\\' );
116 $path = wp_normalize_path( trailingslashit( $path_base ) . $relative );
117 break;
118 }
119 }
120
121 if ( null === $path || ! $this->is_allowed_path( $path ) ) {
122 return null;
123 }
124
125 return ( is_readable( $path ) && is_file( $path ) ) ? $path : null;
126 }
127
128 /**
129 * The image MIME type of a local file, when it is one we render.
130 *
131 * @param string $path Local file path.
132 *
133 * @return string|null
134 */
135 public function mime_type( string $path ): ?string {
136 $extension = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) );
137
138 switch ( $extension ) {
139 case 'png':
140 return 'image/png';
141 case 'jpg':
142 case 'jpeg':
143 return 'image/jpeg';
144 case 'gif':
145 return 'image/gif';
146 case 'webp':
147 return 'image/webp';
148 case 'svg':
149 // Dompdf renders SVG, so PDF receipts with a vector logo depend on
150 // this. GD cannot decode it, so the raster emitter simply skips such
151 // an image rather than drawing a broken one — bytes() hands over the
152 // file either way and the caller decides.
153 return 'image/svg+xml';
154 default:
155 return null;
156 }
157 }
158
159 /**
160 * Build URL-to-path mappings for local WordPress assets.
161 *
162 * Longest URL base first, so a nested mapping wins over its parent.
163 *
164 * @return array<string,string>
165 */
166 private function url_path_mappings(): array {
167 $uploads = wp_upload_dir();
168 $plugin = \dirname( __DIR__, 2 );
169
170 $mappings = array(
171 $uploads['baseurl'] => $uploads['basedir'],
172 content_url() => \defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR : '',
173 plugins_url( '', $plugin . '/woocommerce-pos.php' ) => $plugin,
174 );
175
176 $normalized = array();
177 foreach ( $mappings as $url => $path ) {
178 if ( '' === $url || '' === $path ) {
179 continue;
180 }
181 $normalized[ trailingslashit( $url ) ] = wp_normalize_path( $path );
182 }
183
184 uksort(
185 $normalized,
186 static function ( string $a, string $b ): int {
187 return \strlen( $b ) <=> \strlen( $a );
188 }
189 );
190
191 return $normalized;
192 }
193
194 /**
195 * Check that a resolved path stays within known local asset roots.
196 *
197 * @param string $path Resolved path.
198 *
199 * @return bool
200 */
201 private function is_allowed_path( string $path ): bool {
202 $real_path = realpath( $path );
203 if ( false === $real_path ) {
204 return false;
205 }
206
207 $real_path = wp_normalize_path( $real_path );
208 foreach ( $this->allowed_roots() as $root ) {
209 if ( 0 === strpos( $real_path, trailingslashit( $root ) ) || $real_path === $root ) {
210 return true;
211 }
212 }
213
214 return false;
215 }
216
217 /**
218 * Allowed local image roots.
219 *
220 * @return string[]
221 */
222 private function allowed_roots(): array {
223 $uploads = wp_upload_dir();
224 $roots = array(
225 $uploads['basedir'],
226 \defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR : '',
227 \dirname( __DIR__, 2 ),
228 );
229
230 return array_values(
231 array_filter(
232 array_map(
233 static function ( string $root ): string {
234 $real = realpath( $root );
235
236 return false === $real ? '' : wp_normalize_path( $real );
237 },
238 $roots
239 )
240 )
241 );
242 }
243 }
244