Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.35% covered (warning)
50.35%
72 / 143
9.09% covered (danger)
9.09%
1 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
StagingPath
50.35% covered (warning)
50.35%
72 / 143
9.09% covered (danger)
9.09%
1 / 11
439.83
0.00% covered (danger)
0.00%
0 / 1
 is_staging_abspath
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 parse_staging_from_abspath
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 parse_staging_from_url
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
7.06
 normalize_trailing_slash
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 config_has_swapped_urls
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
6.10
 fix_swapped_urls
77.78% covered (warning)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
8.70
 is_valid_staging_config
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
10.37
 discover_staging_directories
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 select_staging_directory
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 build_config_for_staging_site
51.85% covered (warning)
51.85%
14 / 27
0.00% covered (danger)
0.00%
0 / 1
12.47
 build_config_for_production_site
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace NewfoldLabs\WP\Module\Staging;
4
5/**
6 * Path and URL utilities for deterministic staging locations.
7 *
8 * Staging sites live at {production_dir}/staging/{4-digit-id}/ and
9 * {production_url}/staging/{4-digit-id}.
10 */
11class StagingPath {
12
13    /**
14     * Regex matching a staging ABSPATH suffix.
15     *
16     * @var string
17     */
18    const STAGING_ABSPATH_PATTERN = '#/staging/(\d{4})/?$#';
19
20    /**
21     * Regex matching a staging URL segment.
22     *
23     * @var string
24     */
25    const STAGING_URL_PATTERN = '#/staging/\d{4}(/|$)#';
26
27    /**
28     * Required keys for a valid staging_config option.
29     *
30     * @var string[]
31     */
32    const CONFIG_KEYS = array(
33        'production_dir',
34        'production_url',
35        'staging_dir',
36        'staging_url',
37        'creation_date',
38    );
39
40    /**
41     * Whether ABSPATH matches the staging directory pattern.
42     *
43     * @param string $path Filesystem path (defaults to ABSPATH).
44     * @return bool
45     */
46    public static function is_staging_abspath( $path = null ) {
47        if ( null === $path ) {
48            $path = ABSPATH;
49        }
50
51        return (bool) preg_match( self::STAGING_ABSPATH_PATTERN, self::normalize_trailing_slash( $path ) );
52    }
53
54    /**
55     * Parse production and staging paths from a staging site ABSPATH.
56     *
57     * @param string $abspath Staging site root directory.
58     * @return array|null Keys: production_dir, staging_dir, staging_id; or null.
59     */
60    public static function parse_staging_from_abspath( $abspath ) {
61        $abspath = self::normalize_trailing_slash( $abspath );
62
63        if ( ! preg_match( self::STAGING_ABSPATH_PATTERN, $abspath, $matches ) ) {
64            return null;
65        }
66
67        $staging_id     = $matches[1];
68        $staging_dir    = $abspath;
69        $production_dir = preg_replace( '#/staging/' . preg_quote( $staging_id, '#' ) . '/?$#', '/', $abspath );
70
71        return array(
72            'production_dir' => self::normalize_trailing_slash( $production_dir ),
73            'staging_dir'    => $staging_dir,
74            'staging_id'     => $staging_id,
75        );
76    }
77
78    /**
79     * Parse production and staging URLs from a staging site URL.
80     *
81     * @param string $url Site URL (e.g. site_url()).
82     * @return array|null Keys: production_url, staging_url, staging_id; or null.
83     */
84    public static function parse_staging_from_url( $url ) {
85        $parsed = wp_parse_url( $url );
86        if ( empty( $parsed['path'] ) ) {
87            return null;
88        }
89
90        $path = trailingslashit( $parsed['path'] );
91
92        if ( ! preg_match( '#/staging/(\d{4})/?$#', $path, $matches ) ) {
93            return null;
94        }
95
96        $staging_id      = $matches[1];
97        $staging_path    = '/staging/' . $staging_id;
98        $production_path = preg_replace( '#' . preg_quote( $staging_path, '#' ) . '/?$#', '/', $path );
99        $production_path = ( '/' === $production_path ) ? '' : untrailingslashit( $production_path );
100
101        $scheme = isset( $parsed['scheme'] ) ? $parsed['scheme'] . '://' : '';
102        $host   = isset( $parsed['host'] ) ? $parsed['host'] : '';
103        $port   = isset( $parsed['port'] ) ? ':' . $parsed['port'] : '';
104
105        $base = $scheme . $host . $port;
106
107        return array(
108            'production_url' => untrailingslashit( $base . $production_path ),
109            'staging_url'    => untrailingslashit( $base . $staging_path ),
110            'staging_id'     => $staging_id,
111        );
112    }
113
114    /**
115     * Normalize a directory path with a trailing slash.
116     *
117     * @param string $path Directory path.
118     * @return string
119     */
120    public static function normalize_trailing_slash( $path ) {
121        return trailingslashit( wp_normalize_path( $path ) );
122    }
123
124    /**
125     * Whether staging_config URLs appear swapped (DEVSUP-135985).
126     *
127     * @param array $config staging_config option value.
128     * @return bool
129     */
130    public static function config_has_swapped_urls( array $config ) {
131        $staging_url    = isset( $config['staging_url'] ) ? (string) $config['staging_url'] : '';
132        $production_url = isset( $config['production_url'] ) ? (string) $config['production_url'] : '';
133
134        if ( '' === $staging_url || '' === $production_url ) {
135            return false;
136        }
137
138        $staging_has_segment    = (bool) preg_match( self::STAGING_URL_PATTERN, $staging_url );
139        $production_has_segment = (bool) preg_match( self::STAGING_URL_PATTERN, $production_url );
140
141        return ! $staging_has_segment && $production_has_segment;
142    }
143
144    /**
145     * Swap production and staging URL (and directory) values in config.
146     *
147     * @param array $config staging_config option value.
148     * @return array
149     */
150    public static function fix_swapped_urls( array $config ) {
151        if ( ! self::config_has_swapped_urls( $config ) ) {
152            return $config;
153        }
154
155        $swapped = $config;
156
157        if ( isset( $config['production_url'], $config['staging_url'] ) ) {
158            $swapped['production_url'] = $config['staging_url'];
159            $swapped['staging_url']    = $config['production_url'];
160        }
161
162        if ( isset( $config['production_dir'], $config['staging_dir'] ) ) {
163            $prod_dir = $config['production_dir'];
164            $stg_dir  = $config['staging_dir'];
165
166            $prod_is_staging_path = self::is_staging_abspath( $prod_dir );
167            $stg_is_staging_path  = self::is_staging_abspath( $stg_dir );
168
169            if ( $prod_is_staging_path && ! $stg_is_staging_path ) {
170                $swapped['production_dir'] = $stg_dir;
171                $swapped['staging_dir']    = $prod_dir;
172            } elseif ( $prod_is_staging_path || $stg_is_staging_path ) {
173                $swapped['production_dir'] = $stg_dir;
174                $swapped['staging_dir']    = $prod_dir;
175            }
176        }
177
178        return $swapped;
179    }
180
181    /**
182     * Whether staging_config contains all required keys with plausible values.
183     *
184     * @param array $config staging_config option value.
185     * @return bool
186     */
187    public static function is_valid_staging_config( array $config ) {
188        foreach ( self::CONFIG_KEYS as $key ) {
189            if ( empty( $config[ $key ] ) || ! is_string( $config[ $key ] ) ) {
190                return false;
191            }
192        }
193
194        if ( ! preg_match( self::STAGING_URL_PATTERN, $config['staging_url'] ) ) {
195            return false;
196        }
197
198        if ( preg_match( self::STAGING_URL_PATTERN, $config['production_url'] ) ) {
199            return false;
200        }
201
202        if ( ! self::is_staging_abspath( $config['staging_dir'] ) ) {
203            return false;
204        }
205
206        if ( self::is_staging_abspath( $config['production_dir'] ) ) {
207            return false;
208        }
209
210        return true;
211    }
212
213    /**
214     * Discover valid staging directories under a production root.
215     *
216     * @param string $production_dir Production ABSPATH.
217     * @return string[] Normalized staging directory paths.
218     */
219    public static function discover_staging_directories( $production_dir ) {
220        $production_dir = self::normalize_trailing_slash( $production_dir );
221        $staging_parent = $production_dir . 'staging/';
222
223        if ( ! is_dir( $staging_parent ) ) {
224            return array();
225        }
226
227        $found = array();
228
229        $entries = glob( $staging_parent . '[0-9][0-9][0-9][0-9]', GLOB_ONLYDIR );
230        if ( false === $entries ) {
231            return array();
232        }
233
234        foreach ( $entries as $dir ) {
235            if ( file_exists( $dir . '/wp-config.php' ) ) {
236                $found[] = self::normalize_trailing_slash( $dir );
237            }
238        }
239
240        return $found;
241    }
242
243    /**
244     * Pick the best staging directory when multiple exist on disk.
245     *
246     * @param string   $production_dir Production ABSPATH.
247     * @param string[] $directories    Candidate staging directories.
248     * @param array    $config         Current staging_config (may be partial).
249     * @return string|null
250     */
251    public static function select_staging_directory( $production_dir, array $directories, array $config = array() ) {
252        if ( empty( $directories ) ) {
253            return null;
254        }
255
256        if ( 1 === count( $directories ) ) {
257            return $directories[0];
258        }
259
260        if ( ! empty( $config['staging_dir'] ) ) {
261            $preferred = self::normalize_trailing_slash( $config['staging_dir'] );
262            if ( in_array( $preferred, $directories, true ) ) {
263                return $preferred;
264            }
265        }
266
267        usort(
268            $directories,
269            function ( $a, $b ) {
270                return filemtime( $b ) - filemtime( $a );
271            }
272        );
273
274        return $directories[0];
275    }
276
277    /**
278     * Build a full staging_config array for a staging site from path and URL.
279     *
280     * @param string $abspath   Staging ABSPATH.
281     * @param string $site_url  Current site URL.
282     * @param array  $existing  Existing config to preserve creation_date when possible.
283     * @return array|null
284     */
285    public static function build_config_for_staging_site( $abspath, $site_url, array $existing = array() ) {
286        $path_data = self::parse_staging_from_abspath( $abspath );
287        if ( null === $path_data ) {
288            return null;
289        }
290
291        $url_data = self::parse_staging_from_url( $site_url );
292        if ( null === $url_data ) {
293            $url_data = array(
294                'production_url' => untrailingslashit( $site_url ),
295                'staging_url'    => untrailingslashit( $site_url ),
296                'staging_id'     => $path_data['staging_id'],
297            );
298
299            $staging_segment = '/staging/' . $path_data['staging_id'];
300            if ( false !== strpos( $site_url, $staging_segment ) ) {
301                $url_data['production_url'] = str_replace( $staging_segment, '', untrailingslashit( $site_url ) );
302                $url_data['staging_url']    = untrailingslashit( $site_url );
303            } else {
304                $url_data['staging_url']    = $url_data['production_url'] . $staging_segment;
305                $url_data['production_url'] = untrailingslashit( str_replace( $staging_segment, '', $site_url ) );
306            }
307        }
308
309        $dir_mtime     = file_exists( $path_data['staging_dir'] ) ? filemtime( $path_data['staging_dir'] ) : false;
310        $creation_date = ! empty( $existing['creation_date'] )
311            ? $existing['creation_date']
312            : gmdate( 'M j, Y', false !== $dir_mtime ? $dir_mtime : time() );
313
314        return array(
315            'creation_date'  => $creation_date,
316            'production_dir' => $path_data['production_dir'],
317            'production_url' => $url_data['production_url'],
318            'staging_dir'    => $path_data['staging_dir'],
319            'staging_url'    => $url_data['staging_url'],
320        );
321    }
322
323    /**
324     * Build staging_config for a production site from an on-disk staging directory.
325     *
326     * @param string $staging_dir    Staging directory path.
327     * @param string $production_dir Production ABSPATH.
328     * @param string $production_url Production site URL.
329     * @param array  $existing       Existing config to preserve creation_date when possible.
330     * @return array|null
331     */
332    public static function build_config_for_production_site( $staging_dir, $production_dir, $production_url, array $existing = array() ) {
333        $staging_dir    = self::normalize_trailing_slash( $staging_dir );
334        $production_dir = self::normalize_trailing_slash( $production_dir );
335
336        $path_data = self::parse_staging_from_abspath( $staging_dir );
337        if ( null === $path_data ) {
338            return null;
339        }
340
341        $staging_id  = $path_data['staging_id'];
342        $staging_url = untrailingslashit( $production_url ) . '/staging/' . $staging_id;
343
344        $dir_mtime     = file_exists( $staging_dir ) ? filemtime( $staging_dir ) : false;
345        $creation_date = ! empty( $existing['creation_date'] )
346            ? $existing['creation_date']
347            : gmdate( 'M j, Y', false !== $dir_mtime ? $dir_mtime : time() );
348
349        return array(
350            'creation_date'  => $creation_date,
351            'production_dir' => $production_dir,
352            'production_url' => untrailingslashit( $production_url ),
353            'staging_dir'    => $staging_dir,
354            'staging_url'    => $staging_url,
355        );
356    }
357}