Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
44.71% covered (danger)
44.71%
76 / 170
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
StagingHealthCheck
44.71% covered (danger)
44.71%
76 / 170
40.00% covered (danger)
40.00%
4 / 10
668.61
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 maybe_repair
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 get_notice_transient_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 repair_staging_site
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
182
 repair_production_site
57.35% covered (warning)
57.35%
39 / 68
0.00% covered (danger)
0.00%
0 / 1
55.21
 merge_config_with_expected
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
210
 save_config
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 update_environment
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 schedule_admin_notice
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 log
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
4.07
1<?php
2
3namespace NewfoldLabs\WP\Module\Staging;
4
5/**
6 * Detects and repairs inconsistent staging metadata (wp_options vs filesystem).
7 */
8class StagingHealthCheck {
9
10    /**
11     * Repair log filename under nfd-private.
12     *
13     * @var string
14     */
15    const REPAIR_LOG = 'nfd-staging-repair.log';
16
17    /**
18     * Transient key shown once as an admin notice after repair.
19     *
20     * @var string
21     */
22    const NOTICE_TRANSIENT = 'nfd_staging_repair_ran';
23
24    /**
25     * Staging service instance.
26     *
27     * @var Staging
28     */
29    protected $staging;
30
31    /**
32     * Whether any repair was applied during this request.
33     *
34     * @var bool
35     */
36    protected $repaired = false;
37
38    /**
39     * Constructor.
40     *
41     * @param Staging $staging Staging module instance.
42     */
43    public function __construct( Staging $staging ) {
44        $this->staging = $staging;
45    }
46
47    /**
48     * Run health check and apply fixes when needed.
49     *
50     * @return bool True if any option was updated or deleted.
51     */
52    public function maybe_repair() {
53        $this->repaired = false;
54
55        if ( StagingPath::is_staging_abspath( ABSPATH ) ) {
56            $this->repair_staging_site();
57        } else {
58            $this->repair_production_site();
59        }
60
61        if ( $this->repaired ) {
62            $this->schedule_admin_notice();
63        }
64
65        return $this->repaired;
66    }
67
68    /**
69     * Transient key for the admin notice.
70     *
71     * @return string
72     */
73    public static function get_notice_transient_key() {
74        return self::NOTICE_TRANSIENT;
75    }
76
77    /**
78     * Repair metadata on a staging installation.
79     *
80     * @return void
81     */
82    protected function repair_staging_site() {
83        $config   = $this->staging->getConfig( false );
84        $site_url = site_url();
85        $env      = $this->staging->getEnvironment();
86        $wp_env   = defined( 'WP_ENVIRONMENT_TYPE' ) ? WP_ENVIRONMENT_TYPE : null;
87
88        $this->log(
89            'INFO',
90            'staging_check',
91            sprintf(
92                'Staging site detected. ABSPATH=%s environment=%s WP_ENVIRONMENT_TYPE=%s',
93                ABSPATH,
94                $env,
95                $wp_env ? $wp_env : 'undefined'
96            )
97        );
98
99        if ( 'staging' !== $env ) {
100            $this->update_environment( 'staging', 'staging_environment was missing or incorrect' );
101        }
102
103        $expected = StagingPath::build_config_for_staging_site( ABSPATH, $site_url, $config );
104
105        if ( null === $expected ) {
106            $this->log( 'WARN', 'staging_check', 'Unable to derive config from ABSPATH.' );
107            return;
108        }
109
110        if ( empty( $config ) || ! StagingPath::is_valid_staging_config( $config ) ) {
111            $this->save_config( $expected, 'reconstructed staging_config from ABSPATH and site URL' );
112            $config = $expected;
113        } else {
114            $merged = $this->merge_config_with_expected( $config, $expected );
115            if ( $merged !== $config ) {
116                $this->save_config( $merged, 'aligned staging_config with ABSPATH-derived values' );
117                $config = $merged;
118            }
119        }
120
121        if ( StagingPath::config_has_swapped_urls( $config ) ) {
122            $fixed = StagingPath::fix_swapped_urls( $config );
123            $this->save_config( $fixed, 'fixed swapped production and staging URLs' );
124            $config = $fixed;
125        }
126
127        if ( $wp_env && 'staging' === $wp_env && 'production' === $this->staging->getEnvironment() ) {
128            $this->update_environment( 'staging', 'WP_ENVIRONMENT_TYPE is staging; corrected staging_environment' );
129        }
130
131        if ( ! $this->repaired ) {
132            $this->log( 'INFO', 'staging_check', 'No repairs required.' );
133        }
134    }
135
136    /**
137     * Repair metadata on a production installation.
138     *
139     * @return void
140     */
141    protected function repair_production_site() {
142        $config         = $this->staging->getConfig( false );
143        $production_dir = StagingPath::normalize_trailing_slash( ABSPATH );
144        $production_url = site_url();
145        $env            = $this->staging->getEnvironment();
146        $wp_env_staging = defined( 'WP_ENVIRONMENT_TYPE' ) && 'staging' === WP_ENVIRONMENT_TYPE;
147
148        $this->log(
149            'INFO',
150            'production_check',
151            sprintf(
152                'Production site check. ABSPATH=%s environment=%s config_empty=%s',
153                ABSPATH,
154                $env,
155                empty( $config ) ? 'yes' : 'no'
156            )
157        );
158
159        if ( 'production' !== $env && ! $wp_env_staging ) {
160            $this->update_environment( 'production', 'staging_environment was incorrect on production' );
161        }
162
163        if ( ! empty( $config ) && StagingPath::config_has_swapped_urls( $config ) ) {
164            $fixed = StagingPath::fix_swapped_urls( $config );
165            $this->save_config( $fixed, 'fixed swapped production and staging URLs' );
166            $config = $fixed;
167        }
168
169        if ( ! empty( $config ) ) {
170            $staging_dir = isset( $config['staging_dir'] ) ? $config['staging_dir'] : '';
171
172            if ( ! empty( $staging_dir ) && ! file_exists( $staging_dir ) ) {
173                $this->log(
174                    'INFO',
175                    'orphan_cleanup',
176                    sprintf( 'staging_dir missing on disk: %s', $staging_dir )
177                );
178                delete_option( 'staging_config' );
179                $this->repaired = true;
180                $config         = array();
181                $this->log( 'FIX', 'orphan_cleanup', 'Deleted orphaned staging_config.' );
182            }
183        }
184
185        if ( empty( $config ) ) {
186            $directories = StagingPath::discover_staging_directories( $production_dir );
187
188            if ( ! empty( $directories ) ) {
189                $selected = StagingPath::select_staging_directory( $production_dir, $directories, $config );
190
191                if ( count( $directories ) > 1 ) {
192                    $this->log(
193                        'WARN',
194                        'discover',
195                        sprintf(
196                            'Multiple staging directories found (%d); using %s',
197                            count( $directories ),
198                            $selected
199                        )
200                    );
201                }
202
203                $rebuilt = StagingPath::build_config_for_production_site(
204                    $selected,
205                    $production_dir,
206                    $production_url,
207                    $config
208                );
209
210                if ( null !== $rebuilt ) {
211                    $this->save_config( $rebuilt, 'reconstructed staging_config from on-disk staging directory' );
212                }
213            }
214        } elseif ( ! StagingPath::is_valid_staging_config( $config ) ) {
215            $staging_dir = isset( $config['staging_dir'] ) ? $config['staging_dir'] : '';
216
217            if ( ! empty( $staging_dir ) && file_exists( $staging_dir ) ) {
218                $rebuilt = StagingPath::build_config_for_production_site(
219                    $staging_dir,
220                    $production_dir,
221                    $production_url,
222                    $config
223                );
224
225                if ( null !== $rebuilt ) {
226                    $this->save_config( $rebuilt, 'rebuilt invalid staging_config from existing staging_dir' );
227                }
228            }
229        }
230
231        if ( ! $this->repaired ) {
232            $this->log( 'INFO', 'production_check', 'No repairs required.' );
233        }
234    }
235
236    /**
237     * Merge stored config with path-derived values when paths disagree.
238     *
239     * @param array $config   Current config.
240     * @param array $expected Expected config from ABSPATH.
241     * @return array
242     */
243    protected function merge_config_with_expected( array $config, array $expected ) {
244        $merged = $config;
245
246        foreach ( array( 'production_dir', 'staging_dir', 'production_url', 'staging_url' ) as $key ) {
247            if ( ! isset( $expected[ $key ] ) ) {
248                continue;
249            }
250
251            if ( ! isset( $merged[ $key ] ) || $merged[ $key ] !== $expected[ $key ] ) {
252                if ( 'production_dir' === $key && StagingPath::is_staging_abspath( $merged[ $key ] ?? '' ) ) {
253                    $merged[ $key ] = $expected[ $key ];
254                    continue;
255                }
256
257                if ( 'staging_dir' === $key && ! StagingPath::is_staging_abspath( $merged[ $key ] ?? '' ) ) {
258                    $merged[ $key ] = $expected[ $key ];
259                    continue;
260                }
261
262                if ( in_array( $key, array( 'production_url', 'staging_url' ), true ) ) {
263                    if ( 'production_url' === $key && preg_match( StagingPath::STAGING_URL_PATTERN, $merged[ $key ] ?? '' ) ) {
264                        $merged[ $key ] = $expected[ $key ];
265                    } elseif ( 'staging_url' === $key && ! preg_match( StagingPath::STAGING_URL_PATTERN, $merged[ $key ] ?? '' ) ) {
266                        $merged[ $key ] = $expected[ $key ];
267                    }
268                } else {
269                    $merged[ $key ] = $expected[ $key ];
270                }
271            }
272        }
273
274        return $merged;
275    }
276
277    /**
278     * Persist staging_config and log the change.
279     *
280     * @param array  $config  New config value.
281     * @param string $reason  Human-readable reason for the log.
282     * @return void
283     */
284    protected function save_config( array $config, $reason ) {
285        $before = $this->staging->getConfig( false );
286        update_option( 'staging_config', $config );
287        $this->repaired = true;
288        $this->log(
289            'FIX',
290            'save_config',
291            sprintf(
292                '%s. before=%s after=%s',
293                $reason,
294                wp_json_encode( $before ),
295                wp_json_encode( $config )
296            )
297        );
298    }
299
300    /**
301     * Update staging_environment and log the change.
302     *
303     * @param string $value  production|staging.
304     * @param string $reason Log message.
305     * @return void
306     */
307    protected function update_environment( $value, $reason ) {
308        $before = get_option( 'staging_environment', 'production' );
309        update_option( 'staging_environment', $value );
310        $this->repaired = true;
311        $this->log(
312            'FIX',
313            'update_environment',
314            sprintf( '%s. before=%s after=%s', $reason, $before, $value )
315        );
316    }
317
318    /**
319     * Set transient so Staging can show a one-time admin notice.
320     *
321     * @return void
322     */
323    protected function schedule_admin_notice() {
324        set_transient( self::NOTICE_TRANSIENT, 1, HOUR_IN_SECONDS );
325    }
326
327    /**
328     * Write a line to the repair log under production nfd-private.
329     *
330     * @param string $level Log level (INFO, WARN, FIX).
331     * @param string $step  Step identifier.
332     * @param string $message Log message.
333     * @return void
334     */
335    protected function log( $level, $step, $message ) {
336        $production_dir = StagingPath::is_staging_abspath( ABSPATH )
337            ? StagingPath::parse_staging_from_abspath( ABSPATH )['production_dir']
338            : StagingPath::normalize_trailing_slash( ABSPATH );
339
340        $log_dir  = $production_dir . 'nfd-private';
341        $log_file = $log_dir . '/' . self::REPAIR_LOG;
342
343        if ( ! is_dir( $log_dir ) ) {
344            wp_mkdir_p( $log_dir );
345        }
346
347        $htaccess = $log_dir . '/.htaccess';
348        if ( ! file_exists( $htaccess ) ) {
349            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
350            file_put_contents( $htaccess, "Order allow,deny\nDeny from all\n" );
351        }
352
353        $line = sprintf(
354            "%s [%s] [%s] %s\n",
355            gmdate( 'Y-m-d H:i:s' ),
356            $level,
357            $step,
358            $message
359        );
360
361        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
362        file_put_contents( $log_file, $line, FILE_APPEND | LOCK_EX );
363    }
364}