PluginProbe
Redirection / 5.6.0
Redirection v5.6.0
5.10.0 5.9.0 5.8.1 5.8.0 3.7.2 3.7.3 4.0 4.0.1 4.1 4.1.1 4.2 4.2.1 4.2.2 4.2.3 4.3 4.3.1 4.3.2 4.3.3 4.4 4.4.1 4.4.2 4.5 4.5.1 4.6.2 4.7.1 All 130 releases
redirection / models / htaccess.php

htaccess.php in Redirection 5.6.0, at models/htaccess.php

513 lines 13.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Convert redirects to .htaccess format
5 *
6 * Ignores:
7 * - Trailing slash flag
8 * - Query flags
9 */
10 class Red_Htaccess {
11 /**
12 * Array of redirect lines
13 *
14 * @var array<string>
15 */
16 private $items = array();
17
18 const INSERT_REGEX = '@\n?# Created by Redirection(?:.*?)# End of Redirection\n?@sm';
19
20 /**
21 * Encode the 'from' URL
22 *
23 * @param string $url From URL.
24 * @param bool $ignore_trailing Ignore trailing slashes.
25 * @return string
26 */
27 private function encode_from( $url, $ignore_trailing ) {
28 $url = $this->encode( $url );
29
30 // Apache 2 does not need a leading slashing
31 $url = ltrim( $url, '/' );
32
33 if ( $ignore_trailing ) {
34 $url = rtrim( $url, '/' ) . '/?';
35 }
36
37 // Exactly match the URL
38 return '^' . $url . '$';
39 }
40
41 /**
42 * URL encode some things, but other things can be passed through
43 *
44 * @param string $url URL.
45 * @return string
46 */
47 private function encode2nd( $url ) {
48 $allowed = [
49 '%2F' => '/',
50 '%3F' => '?',
51 '%3A' => ':',
52 '%3D' => '=',
53 '%26' => '&',
54 '%25' => '%',
55 '+' => '%20',
56 '%24' => '$',
57 '%23' => '#',
58 ];
59
60 $url = rawurlencode( $url );
61 return $this->replace_encoding( $url, $allowed );
62 }
63
64 /**
65 * Replace encoded characters in a URL
66 *
67 * @param string $str Source string.
68 * @param array<string, string> $allowed Allowed encodings.
69 * @return string
70 */
71 private function replace_encoding( $str, array $allowed ) {
72 foreach ( $allowed as $before => $after ) {
73 $str = str_replace( $before, $after, $str );
74 }
75
76 return $str;
77 }
78
79 /**
80 * Encode a URL
81 *
82 * @param string $url URL.
83 * @return string
84 */
85 private function encode( $url ) {
86 $allowed = [
87 '%2F' => '/',
88 '%3F' => '?',
89 '+' => ' ',
90 '.' => '\\.',
91 '%20' => ' ',
92 ];
93
94 return $this->replace_encoding( rawurlencode( $url ), $allowed );
95 }
96
97 /**
98 * Encode a regex URL
99 *
100 * @param string $url URL.
101 * @return string
102 */
103 private function encode_regex( $url ) {
104 // Remove any newlines
105 $url = (string) preg_replace( "/[\r\n\t].*?$/s", '', $url );
106
107 // Remove invalid characters
108 $url = (string) preg_replace( '/[^\PC\s]/u', '', $url );
109
110 // Make sure spaces are quoted
111 // $url = str_replace( ' ', '%20', $url );
112 $url = str_replace( '%24', '$', $url );
113
114 // No leading slash
115 $url = ltrim( $url, '/' );
116
117 // If pattern has a ^ at the start then ensure we don't have a slash immediatley after
118 $url = (string) preg_replace( '@^\^/@', '^', $url );
119
120 return $url;
121 }
122
123 /**
124 * Add a referrer redirect
125 *
126 * @param Red_Item $item Redirect item.
127 * @param Referrer_Match $match_object Redirect match.
128 * @return void
129 */
130 private function add_referrer( $item, $match_object ) {
131 $from = $this->encode_from( ltrim( $item->get_url(), '/' ), $item->source_flags !== null && $item->source_flags->is_ignore_trailing() );
132 if ( $item->is_regex() ) {
133 $from = $this->encode_regex( ltrim( $item->get_url(), '/' ) );
134 }
135
136 if ( ( $match_object->url_from !== '' || $match_object->url_notfrom !== '' ) && $match_object->referrer !== '' ) {
137 $referrer = $match_object->regex ? $this->encode_regex( $match_object->referrer ) : $this->encode_from( $match_object->referrer, false );
138 $to = false;
139 $match_data = $item->get_match_data();
140
141 if ( $match_object->url_from !== '' && $match_data !== null ) {
142 $to = $this->target( $item->get_action_type(), $match_object->url_from, $item->get_action_code(), $match_data );
143 }
144
145 if ( $match_object->url_notfrom !== '' && $match_data !== null ) {
146 $to = $this->target( $item->get_action_type(), $match_object->url_notfrom, $item->get_action_code(), $match_data );
147 }
148
149 $this->items[] = sprintf( 'RewriteCond %%{HTTP_REFERER} %s [NC]', $referrer );
150 if ( $to !== false ) {
151 $this->items[] = sprintf( 'RewriteRule %s %s', $from, $to );
152 }
153 }
154 }
155
156 /**
157 * Add a useragent redirect
158 *
159 * @param Red_Item $item Redirect item.
160 * @param Agent_Match $match_object Redirect match.
161 * @return void
162 */
163 private function add_agent( $item, $match_object ) {
164 $from = $this->encode( ltrim( $item->get_url(), '/' ) );
165 if ( $item->is_regex() ) {
166 $from = $this->encode_regex( ltrim( $item->get_url(), '/' ) );
167 }
168
169 if ( ( $match_object->url_from !== '' || $match_object->url_notfrom !== '' ) && $match_object->agent !== '' ) {
170 $agent = ( $match_object->regex ? $this->encode_regex( $match_object->agent ) : $this->encode2nd( $match_object->agent ) );
171 $to = false;
172 $match_data = $item->get_match_data();
173
174 if ( $match_object->url_from !== '' && $match_data !== null ) {
175 $to = $this->target( $item->get_action_type(), $match_object->url_from, $item->get_action_code(), $match_data );
176 }
177
178 if ( $match_object->url_notfrom !== '' && $match_data !== null ) {
179 $to = $this->target( $item->get_action_type(), $match_object->url_notfrom, $item->get_action_code(), $match_data );
180 }
181
182 $this->items[] = sprintf( 'RewriteCond %%{HTTP_USER_AGENT} %s [NC]', $agent );
183 if ( $to !== false ) {
184 $this->items[] = sprintf( 'RewriteRule %s %s', $from, $to );
185 }
186 }
187 }
188
189 /**
190 * Add a server redirect
191 *
192 * @param Red_Item $item Redirect item.
193 * @param Server_Match $match_object Redirect match.
194 * @return void
195 */
196 private function add_server( $item, $match_object ) {
197 // Temporarily set url property for add_url method
198 $host = wp_parse_url( $match_object->server, PHP_URL_HOST );
199 if ( is_string( $host ) ) {
200 $this->items[] = sprintf( 'RewriteCond %%{HTTP_HOST} ^%s$ [NC]', preg_quote( $host, '/' ) );
201 }
202 $this->add_url( $item, $match_object->url_from );
203 }
204
205 /**
206 * Add a redirect
207 *
208 * @param Red_Item $item Redirect item.
209 * @param string $target_url Target URL.
210 * @return void
211 */
212 private function add_url( $item, $target_url ) {
213 $url = $item->get_url();
214
215 if ( $item->is_regex() === false && strpos( $url, '?' ) !== false ) {
216 $url_parts = wp_parse_url( $url );
217
218 if ( isset( $url_parts['path'] ) ) {
219 $url = $url_parts['path'];
220 $query = isset( $url_parts['query'] ) ? $url_parts['query'] : '';
221 $this->items[] = sprintf( 'RewriteCond %%{QUERY_STRING} ^%s$', $query );
222 }
223 }
224
225 $to = '';
226 $match_data = $item->get_match_data();
227 if ( $match_data !== null && $target_url !== '' ) {
228 $to = $this->target( $item->get_action_type(), $target_url, $item->get_action_code(), $match_data );
229 }
230
231 $from = $this->encode_from( $url, $item->source_flags !== null && $item->source_flags->is_ignore_trailing() );
232
233 if ( $item->is_regex() ) {
234 $from = $this->encode_regex( $item->get_url() );
235 }
236
237 if ( $to !== '' ) {
238 $this->items[] = sprintf( 'RewriteRule %s %s', trim( $from ), trim( $to ) );
239 }
240 }
241
242 /**
243 * Add a redirect flags
244 *
245 * @param string $current Current redirect rule.
246 * @param array<string> $flags Flags to add.
247 * @return string
248 */
249 private function add_flags( string $current, array $flags ) {
250 return $current . ' [' . implode( ',', $flags ) . ']';
251 }
252
253 /**
254 * Get source flags
255 *
256 * @param array<string> $existing Existing flags.
257 * @param array<string, mixed> $source Source flags.
258 * @param string $url URL.
259 * @return array<string>
260 */
261 private function get_source_flags( array $existing, array $source, string $url ) {
262 $flags = [];
263
264 if ( isset( $source['flag_case'] ) && $source['flag_case'] !== false ) {
265 $flags[] = 'NC';
266 }
267
268 if ( isset( $source['flag_query'] ) && $source['flag_query'] === 'pass' ) {
269 $flags[] = 'QSA';
270 }
271
272 if ( strpos( $url, '#' ) !== false || strpos( $url, '%' ) !== false ) {
273 $flags[] = 'NE';
274 }
275
276 return array_merge( $existing, $flags );
277 }
278
279 /**
280 * Add a random target.
281 *
282 * @param string $data Target URL data.
283 * @param int $code HTTP status code.
284 * @param array<string, mixed> $match_data Match data including source flags.
285 * @return string
286 */
287 private function action_random( string $data, int $code, array $match_data ) {
288 // Pick a WP post at random
289 global $wpdb;
290
291 $post = $wpdb->get_var( "SELECT ID FROM {$wpdb->posts} ORDER BY RAND() LIMIT 0,1" );
292 $permalink = get_permalink( $post );
293 if ( $permalink === false ) {
294 return '';
295 }
296
297 $url = wp_parse_url( $permalink );
298 if ( $url === false || ! isset( $url['path'] ) ) {
299 return '';
300 }
301
302 $flags = [ sprintf( 'R=%d', $code ) ];
303 $flags[] = 'L';
304 $flags = $this->get_source_flags( $flags, $match_data['source'], $data );
305
306 return $this->add_flags( $this->encode( $url['path'] ), $flags );
307 }
308
309 /**
310 * Add a passthrough target.
311 *
312 * @param string $data Target URL data.
313 * @param int $code HTTP status code.
314 * @param array<string, mixed> $match_data Match data including source flags.
315 * @return string
316 */
317 private function action_pass( string $data, int $code, array $match_data ) {
318 $flags = $this->get_source_flags( [ 'L' ], $match_data['source'], $data );
319
320 return $this->add_flags( $this->encode2nd( $data ), $flags );
321 }
322
323 /**
324 * Add an error target.
325 *
326 * @param string $data Target URL data.
327 * @param int $code HTTP status code.
328 * @param array<string, mixed> $match_data Match data including source flags.
329 * @return string
330 */
331 private function action_error( string $data, int $code, array $match_data ) {
332 $flags = $this->get_source_flags( [ 'F' ], $match_data['source'], $data );
333
334 if ( $code === 410 ) {
335 $flags = $this->get_source_flags( [ 'G' ], $match_data['source'], $data );
336 }
337
338 return $this->add_flags( '/', $flags );
339 }
340
341 /**
342 * Add a URL target.
343 *
344 * @param string $data Target URL data.
345 * @param int $code HTTP status code.
346 * @param array<string, mixed> $match_data Match data including source flags.
347 * @return string
348 */
349 private function action_url( string $data, int $code, array $match_data ) {
350 $flags = [ sprintf( 'R=%d', $code ) ];
351 $flags[] = 'L';
352 $flags = $this->get_source_flags( $flags, $match_data['source'], $data );
353
354 return $this->add_flags( $this->encode2nd( $data ), $flags );
355 }
356
357 /**
358 * Return URL target
359 *
360 * @param string $action Action type.
361 * @param string $data Target URL data.
362 * @param int $code HTTP status code.
363 * @param array<string, mixed> $match_data Match data including source flags.
364 * @return string
365 */
366 private function target( string $action, string $data, int $code, array $match_data ) {
367 $target = 'action_' . $action;
368
369 if ( method_exists( $this, $target ) ) {
370 return $this->$target( $data, $code, $match_data ); // @phpstan-ignore-line
371 }
372
373 return '';
374 }
375
376 /**
377 * Generate the .htaccess file in memory
378 *
379 * @return string
380 */
381 private function generate() {
382 $version = red_get_plugin_data( dirname( __DIR__ ) . '/redirection.php' );
383
384 if ( count( $this->items ) === 0 ) {
385 return '';
386 }
387
388 $text = [
389 '# Created by Redirection',
390 '# ' . gmdate( 'r' ),
391 '# Redirection ' . trim( $version['Version'] ) . ' - https://redirection.me',
392 '',
393 '<IfModule mod_rewrite.c>',
394 ];
395
396 // Add http => https option
397 $options = Red_Options::get();
398 if ( $options['https'] !== false ) {
399 $text[] = 'RewriteCond %{HTTPS} off';
400 $text[] = 'RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]';
401 }
402
403 // Add redirects
404 $text = array_merge( $text, array_filter( array_map( [ $this, 'sanitize_redirect' ], $this->items ) ) );
405
406 // End of mod_rewrite
407 $text[] = '</IfModule>';
408 $text[] = '';
409
410 // End of redirection section
411 $text[] = '# End of Redirection';
412
413 $text = implode( "\n", $text );
414 return "\n" . $text . "\n";
415 }
416
417 /**
418 * Add a redirect to the file
419 *
420 * @param Red_Item $item Redirect.
421 * @return void
422 */
423 public function add( $item ) {
424 $target = 'add_' . $item->get_match_type();
425
426 if ( method_exists( $this, $target ) && $item->is_enabled() ) {
427 // For URL matches, extract target URL from match object
428 if ( $target === 'add_url' && $item->match instanceof URL_Match ) {
429 $this->add_url( $item, $item->match->url );
430 } else {
431 $this->$target( $item, $item->match ); // @phpstan-ignore-line
432 }
433 }
434 }
435
436 /**
437 * Get the .htaccess file
438 *
439 * @param string|false $existing Existing .htaccess data.
440 * @return string
441 */
442 public function get( $existing = false ) {
443 $text = $this->generate();
444
445 if ( $existing !== false ) {
446 if ( preg_match( self::INSERT_REGEX, $existing ) > 0 ) {
447 $text = (string) preg_replace( self::INSERT_REGEX, str_replace( '$', '\\$', $text ), $existing );
448 } else {
449 $text = $text . "\n" . trim( $existing );
450 }
451 }
452
453 return trim( $text );
454 }
455
456 /**
457 * Sanitize the redirect
458 *
459 * @param string $text Text.
460 * @return string
461 */
462 public function sanitize_redirect( $text ) {
463 $text = str_replace( [ "\r", "\n", "\t" ], '', $text );
464 $text = (string) preg_replace( '/[^\PC\s]/u', '', $text );
465
466 return str_replace( [ '<?', '>' ], '', $text );
467 }
468
469 /**
470 * Sanitize the filename
471 *
472 * @param string $filename Filename.
473 * @return string
474 */
475 public function sanitize_filename( $filename ) {
476 return str_replace( '.php', '', sanitize_text_field( $filename ) );
477 }
478
479 /**
480 * Save the .htaccess to a file
481 *
482 * @param string $filename Filename to save.
483 * @param boolean $content_to_save Content to save (unused parameter).
484 * @return bool
485 */
486 public function save( $filename, $content_to_save = false ) {
487 $existing = false;
488 $filename = $this->sanitize_filename( $filename );
489
490 // Initialize WP_Filesystem
491 global $wp_filesystem;
492 if ( ! function_exists( 'WP_Filesystem' ) ) {
493 require_once ABSPATH . 'wp-admin/includes/file.php';
494 }
495
496 // Initialize the filesystem with direct method
497 if ( WP_Filesystem() === null ) {
498 return false;
499 }
500
501 // Read existing file contents if file exists
502 if ( $wp_filesystem->exists( $filename ) ) {
503 $file_contents = $wp_filesystem->get_contents( $filename );
504 if ( $file_contents !== false ) {
505 $existing = $file_contents;
506 }
507 }
508
509 // Write the file
510 return $wp_filesystem->put_contents( $filename, $this->get( $existing ), FS_CHMOD_FILE );
511 }
512 }
513