(bool) DLOGR_Settings::get( 'logging_enabled' ), 'display' => (string) DLOGR_Settings::get( 'debug_display' ), 'rest_prefix' => function_exists( 'rest_get_url_prefix' ) ? (string) rest_get_url_prefix() : 'wp-json', ); } /** * Whether the file on disk says exactly what it should. * * @return bool */ public static function matches() { $stored = self::read(); return is_array( $stored ) && self::expected_values() === $stored; } /** * Regenerates the file. * * Four steps, each closing its own way of breaking: write beside the target * (same directory, or rename stops being atomic), read the result back, * swap it in with rename(), read the working copy back. * * @return true|WP_Error */ public static function write() { $values = self::expected_values(); $target = self::path(); $text = self::render( $values ); if ( '' === $target || false === $text || ! DLOGR_Log_Dir::ensure_base() ) { return self::write_failed(); } $tmp = $target . '.' . uniqid( '', false ) . '.tmp'; /* * Silenced, here and on the rename() below (заказчик, 2026-10-05): with * the folder closed for writing the write refuses, PHP prints a warning, * and while logging is on that warning lands in our own log - on every * repair attempt. The refusal is not lost: it comes back as the WP_Error * below and goes into the journal as boot_data_write_failed. The same * reasoning as for the log directory, dorabotki.md, пункт 23. */ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions -- See above; WP_Filesystem may ask for FTP credentials, and this runs unattended when the repair puts the file back. if ( false === @file_put_contents( $tmp, $text ) ) { return self::write_failed(); } if ( self::read_file( $tmp ) !== $values ) { self::remove( $tmp ); return new WP_Error( 'dlogr_boot_invalid', 'The generated loader data file did not come back the way it was written; the old one was kept.' ); } // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename -- Silenced as the write above. The point is the atomic swap: WP_Filesystem::move() copies and deletes on some transports, which is exactly the half-written state being avoided here. if ( ! @rename( $tmp, $target ) ) { self::remove( $tmp ); return new WP_Error( 'dlogr_boot_replace_failed', 'Could not replace the loader data file. The previous one is still in place.' ); } if ( ! self::matches() ) { return new WP_Error( 'dlogr_boot_mismatch', 'The loader data file was written but reads back differently.' ); } return true; } /** * The error for a data file that could not be written at all. * * @return WP_Error */ private static function write_failed() { return new WP_Error( 'dlogr_boot_write_failed', sprintf( 'Could not write the loader data file. Check that %s can be written to.', DLOGR_Log_Dir::label() ) ); } /** * Whether the block in wp-config.php points the loader at this site's folder. * * The block names the plugin's folder in uploads once, when it is written. * Moving uploads afterwards - the upload_path option, the UPLOADS constant - * leaves the loader reading a folder the plugin no longer writes to, and * that is the same trouble as a block that does not load the plugin at * all, with the same cure: set it up again (заказчик, 2026-10-11). * * Compared by realpath() where both exist: the block spells the folder * from wp-config.php, WordPress from ABSPATH, and a symbolic link or the * case of a drive letter must not raise a false alarm. * * @return bool */ public static function base_matches() { if ( ! defined( 'DLOGR_BASE_DIR' ) ) { return false; } $named = (string) DLOGR_BASE_DIR; $base = DLOGR_Log_Dir::base(); if ( '' === $base ) { return false; } $named_real = realpath( $named ); $base_real = realpath( $base ); if ( false !== $named_real && false !== $base_real ) { return $named_real === $base_real; } $named = untrailingslashit( wp_normalize_path( $named ) ); return '\\' === DIRECTORY_SEPARATOR ? 0 === strcasecmp( $named, $base ) : $named === $base; } /** * Removes the file. Used on deactivation. * * Switching logging off is a value inside the file, not its absence: a * missing file means "repair me", and the toggle would turn itself back on. * Deleting it is right only when the plugin is off and nobody is left to * repair anything. * * @return bool */ public static function delete() { $file = self::path(); if ( '' === $file || ! file_exists( $file ) ) { return true; } return self::remove( $file ); } /** * Keeps the repair from running for a moment. * * The same transient maybe_repair() sets around its own attempt, read from * the other side: there it means "a repair is under way", here "do not * repair right now". Both amount to the same instruction to anybody who * finds it set. * * ⚠️ Held while a log directory is renamed (2026-09-09). Between the * rename on disk and the new name reaching the database, a repair running * in a parallel request sees the old name and no directory under it - and * would helpfully create that old directory again, empty, next to the * renamed one, and move the marker over to it. Somebody would then find a * directory they never made, which reads as the plugin misbehaving. The * loader alone is safe since 2026-10-11: the marker travels with the * directory, so it finds the renamed one at once. * * Taken **before** the disk is touched, and that order is the whole point: * only a repair beginning after the rename is dangerous - and by then the * lock is already there for it to find. * * A minute, and not because the work takes anything like that: it is the * window this lock uses everywhere else, and an abandoned lock has to * expire on its own if the request dies halfway. * * @return void */ public static function hold_repair() { set_transient( self::LOCK, 1, MINUTE_IN_SECONDS ); } /** * Lets the repair run again. * * ⚠️ Called on the failing path too. A lock left behind would silence the * repair for a minute, and a refused rename is exactly the moment when * something about that directory is wrong and repairing it may be needed. * * The cost, named rather than hidden: if a back-off after a run of failed * repairs was in progress, this cuts it short once and the next attempt * comes earlier than it would have. Against a repair that never runs, that * is a cheap trade. * * @return void */ public static function release_repair() { delete_transient( self::LOCK ); } /** * Ends a run of failed repairs, whoever ended it. * * The repair counts its own failures and backs off to an hour after a run * of them. A directory brought back by the switch or the Status button ends * that run just as well (2026-10-04): without this, the count stayed where * the last failure left it, and the next breakage reached the hour-long * back-off early, on failures that were already history. * * The counter is written only when there is something to clear - this runs * on every successful switch-on, and nearly always finds zero. * * @return void */ public static function repair_settled() { if ( 0 !== (int) DLOGR_Settings::get_state( 'repair_failures' ) ) { DLOGR_Settings::set_state( 'repair_failures', 0 ); } self::release_repair(); } /** * Repairs whatever the loader reported as broken. * * Three separate findings, the first two enough to stop logging on their * own: * * - no data file. The block pasted in by hand before anything was set up * leaves this behind, and so does the file deleted from uploads; * - no marked log directory, while the data file is perfectly valid. * Somebody deleted the directory over FTP, or a sync did, or only the * marker went. Nothing but the loader ever notices this on an ordinary * request, which is why it reports it by constant; * - more than one marked directory, most likely one copied by hand. The * loader picks one of them; ensure() takes the extra markers off. * * The directory is recreated under the name already in the database, never * under a fresh one: a new suffix would abandon the existing logs and change * a path the user may have written down. * * Hooked to wp_loaded rather than plugins_loaded: a plugin may change the * REST prefix on init, and generating earlier would store the wrong one. * * @return void */ public static function maybe_repair() { /* * A block pointing at another folder is not repaired from here: the * plugin would write into its own folder while the loader keeps * reading the other one, every minute, for nothing. The Status tab * asks for the block to be set up again instead. */ if ( ! defined( 'DLOGR_BOOT_DATA' ) || ! self::base_matches() ) { return; } /* * A missing directory counts only when a name is stored. With none * there is nothing to recreate, and turning that into a repair would * put a directory on disk that nobody asked for; the Status tab says * what to do instead. The loader cannot tell the two apart since it no * longer reads the name (2026-10-11), so the question moved here. */ $data_missing = ! DLOGR_BOOT_DATA; $dir_missing = defined( 'DLOGR_BOOT_DIR' ) && ! DLOGR_BOOT_DIR && '' !== DLOGR_Log_Dir::name(); $dir_many = defined( 'DLOGR_BOOT_DIR_MANY' ) && DLOGR_BOOT_DIR_MANY; if ( ! $data_missing && ! $dir_missing && ! $dir_many ) { return; } if ( get_transient( self::LOCK ) ) { return; } /* * Backing off after a run of failures. On a host where uploads is * closed for writing the repair can never succeed, and retrying every * minute forever is pointless: mkdir, failure, journal update, repeat. * The counter lives in the state, not in the journal - see the note by * repair_failures in DLOGR_Settings. */ $failures = (int) DLOGR_Settings::get_state( 'repair_failures' ); $ttl = $failures >= self::BACKOFF_AFTER ? HOUR_IN_SECONDS : MINUTE_IN_SECONDS; set_transient( self::LOCK, 1, $ttl ); $failed = false; $unset = '' === DLOGR_Log_Dir::name(); $found = $unset ? DLOGR_Log_Dir::find_existing() : array(); $was_there = DLOGR_Log_Dir::exists(); $dir = DLOGR_Log_Dir::ensure(); if ( is_wp_error( $dir ) ) { $failed = true; DLOGR_Journal::error( 'dir_repair_failed', $dir->get_error_message() ); } elseif ( in_array( DLOGR_Log_Dir::name(), $found, true ) ) { /* * No name was stored, so ensure() took over a leftover rather than * recreating anything. Said as what it is: the two read very * differently a year later. */ DLOGR_Journal::info( 'dir_adopted', sprintf( 'Log directory %s from an earlier install was taken over.', DLOGR_Log_Dir::name() ) ); } elseif ( $unset ) { /* * No name was stored and none was taken over, so a new directory was * made. Not "restored": nothing was missing under a name. Two ways * here, both written down (2026-10-05): the block pasted into * wp-config.php by hand while nothing else was set up - the manual * path of the enable button since dorabotki.md, пункт 16 - and * leftovers on disk that all refused writes (пункт 21). Until then * the first went unrecorded, and dir_created is what tells a * directory with a random suffix a year later. */ DLOGR_Journal::info( 'dir_created', DLOGR_Log_Dir::created_message( $found ) ); } elseif ( $dir_missing && $was_there ) { /* * The directory was there all along, only without its marker, and a * year later "recreated" would send somebody looking for logs that * were never lost. */ DLOGR_Journal::info( 'dir_marker_restored', sprintf( 'The log directory %s had lost the marker the loader finds it by; the marker has been put back.', DLOGR_Log_Dir::name() ) ); } elseif ( $dir_missing ) { DLOGR_Journal::info( 'dir_restored', sprintf( 'Log directory %s was missing and has been recreated.', DLOGR_Log_Dir::name() ) ); } elseif ( $dir_many ) { DLOGR_Journal::info( 'dir_markers_cleared', sprintf( 'More than one log directory carried the marker of the current one; only %s keeps it now.', DLOGR_Log_Dir::name() ) ); } /* * Checked rather than rewritten blindly: when only the directory was * gone the file is still correct, and rewriting it would mean a * temporary file and a rename for nothing. When the * file is missing or stale, matches() says so - inside the method * below, which is also what admin_init and activation call. */ if ( ! self::repair_boot_data() ) { $failed = true; } if ( $failed ) { DLOGR_Settings::set_state( 'repair_failures', $failures + 1 ); return; } /* * Succeeded: clear the count and drop the lock, so the next breakage is * dealt with at once instead of waiting out a window earned by a failure. */ self::repair_settled(); } /** * Rebuilds boot-data.json when it is missing or out of step with the database. * * The one place that decides whether the loader's data file needs writing, * and the only one that reports the outcome. Three callers, deliberately: * * - admin_init, where drift between the file and the database is worth a * query. The front end has no reason to pay for one; * - maybe_repair(), which wakes up on a loader that reported damage and * handles the log directory as well; * - activate(), because a deactivate deletes the file and the next * activation has to put it back. * * ⚠️ Until 2026-09-09 the first caller was a method of its own, check(), * and maybe_repair() carried a copy of the same three lines. The copies had * already drifted: one wrote to the journal, the other said nothing at all. * * ⚠️ No lock and no failure counter here. Both belong to the automatic * repair loop above, which runs on every request and has to be throttled; * this method is also called by hand, once, by somebody who has just * clicked Activate. Refusing to work because a lock from an earlier failure * is still warm would be exactly the silence this method exists to end. * * @return bool False only when a write was needed and failed. */ public static function repair_boot_data() { /* * Until the block is in wp-config.php the loader never runs, and a data * file has nobody to serve. The constant is the cheapest way to tell: * boot.php defines it in both branches, so its absence means the block * is not there at all. * * ⚠️ This is also what keeps activation off the disk on a fresh * install: no block, no constant, nothing written. * * ⚠️ Nor when the block points the loader at another folder (dorabotki * 29, 2026-10-15) - the same gate as in maybe_repair(), and for the * same reason. Without it the first admin screen after the uploads * folder changed wrote a data file into the new folder, which the * loader never reads, and told the journal it had "rebuilt" one. The * Status tab then even called the file fine. */ if ( ! defined( 'DLOGR_BOOT_DATA' ) || ! self::base_matches() ) { return true; } if ( self::matches() ) { return true; } $written = self::write(); if ( is_wp_error( $written ) ) { DLOGR_Journal::error( 'boot_data_write_failed', $written->get_error_message() ); return false; } DLOGR_Journal::info( 'boot_data_written', 'Loader data file was missing or stale and has been rebuilt.' ); return true; } /** * Whether the server has fixed a PHP setting so that code cannot change it. * * A PHP-FPM pool does exactly that with php_admin_value and php_admin_flag, * and then ini_set() fails quietly - ours in boot.php and core's in * wp_debug_mode() alike. For error_log this means the entries go where the * server says, not to the log directory (dorabotki.md, пункт 5, found on the * test server 2026-10-01). * * ⚠️ Read, not tried: the access mask says it without changing anything. * Measured on the test server - 4 (INI_SYSTEM) when fixed, 7 (INI_ALL) when * free. Only when ini_get_all() is disabled is the setting written back with * its own value, and then the answer is ini_set() === false: on success it * returns the old value, which may well be an empty string. * * Logging is still allowed when error_log is fixed (заказчик, 2026-10-04, * revising 2026-10-01): the plugin still runs WordPress debugging and can * often read the server's file. It says so instead of refusing. * * @param string $directive Name of the php.ini setting. * @return bool */ public static function is_pinned( $directive ) { if ( function_exists( 'ini_get_all' ) ) { $all = ini_get_all( null, true ); if ( is_array( $all ) && isset( $all[ $directive ]['access'] ) ) { return 0 === ( (int) $all[ $directive ]['access'] & INI_USER ); } } $value = ini_get( $directive ); if ( false === $value ) { return false; } // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged, WordPress.PHP.IniSet.Risky -- Writes back the value it just read, which changes nothing; only the answer is wanted. return false === ini_set( $directive, $value ); } /** * Reads the working copy as it is on disk. * * @return array|false */ private static function read() { return self::read_file( self::path() ); } /** * Reads a data file - the working copy, or a fresh one before it is swapped in. * * The plain reading of the file; the loader is the one that judges every * value before using it. Here the result is only ever compared with * expected_values(), and anything else in the file makes it different. * * @param string $file Absolute path. * @return array|false */ private static function read_file( $file ) { if ( '' === $file || ! is_readable( $file ) ) { return false; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- A local file of ours, a few dozen bytes; see write() for why not WP_Filesystem. $json = file_get_contents( $file ); if ( false === $json ) { return false; } $data = json_decode( $json, true ); return is_array( $data ) ? $data : false; } /** * Builds the text of the file. * * @param array $values Values to store. * @return string|false False when the values could not be encoded. */ private static function render( array $values ) { $json = wp_json_encode( $values, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); return false === $json ? false : $json . "\n"; } /** * Deletes a file. * * @param string $file Absolute path. * @return bool */ private static function remove( $file ) { // phpcs:ignore WordPress.WP.AlternativeFunctions -- Same reason as the write: this must work without asking anyone for credentials. return unlink( $file ); } }