get_error_message();
}
}
if ( empty( $failures ) ) {
return true;
}
return new WP_Error( 'dlogr_dir_not_deleted', implode( ' ', $failures ) );
}
/**
* Gives a log directory a fresh random name.
*
* ⚠️ Nothing is moved and nothing is opened. rename() rewrites the entry
* in the parent directory; the files inside stay exactly where they are.
* The deferral written down on 2026-08-15 - "moving the files is a
* mechanism of its own" - rested on a mechanism that does not exist.
* Nothing inside needs rewriting either: none of the files the plugin puts
* there carries the directory name, and the marker travels with the
* directory, so the loader finds it under the new name at once.
*
* ⚠️ The name is generated, never supplied. It is the same rule that
* keeps generate_name() private and ensure() the only way to a new name: the
* random suffix is what keeps the logs from being found by guessing a
* URL, and a name somebody types is a name somebody can guess. This
* operation exists precisely because the old path became known.
*
* What this does not do is update the state or the loader data file.
* Renaming the active directory needs both, in that order, and both
* belong to the caller - the same division adopt() follows.
*
* @param string $name Directory name, without the path.
* @return string|WP_Error The new name.
*/
public static function rename_random( $name ) {
$path = self::path( $name );
if ( '' === $path ) {
return new WP_Error(
'dlogr_not_ours',
sprintf( '%s is not a log directory of this plugin, so it was left alone.', $name )
);
}
if ( ! is_dir( $path ) ) {
return new WP_Error(
'dlogr_dir_missing',
sprintf( 'The log directory %s is not on disk, so there was nothing to rename.', $name )
);
}
$new = self::generate_name();
if ( '' === $new ) {
return new WP_Error(
'dlogr_name_failed',
'Could not generate a name for the log directory. Try again.'
);
}
$target = self::path( $new );
/*
* Asked outright rather than left to rename(), which does different
* things to an existing target depending on the system and on whether
* it is empty. One in 36^8, and still cheaper to check than to explain.
*/
if ( file_exists( $target ) ) {
return new WP_Error(
'dlogr_name_taken',
'The generated name is already taken. Try again.'
);
}
/*
* Silenced for the reason the deletions above are: a refused rename
* raises a warning, and a warning raised here would be written into
* the very directory being renamed. The failure comes back as the
* WP_Error below instead.
*/
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename -- See above; and see the class docblock for why this is not WP_Filesystem.
if ( ! @rename( $path, $target ) ) {
return new WP_Error(
'dlogr_rename_failed',
sprintf(
'The log directory %s could not be renamed, so nothing was changed. A file inside it being held open by another process is the usual reason.',
$name
)
);
}
return $new;
}
/**
* What a log directory holds: 'empty', 'content' or 'unreadable'.
*
* A log directory is never literally empty - the files in PROTECTIVE_FILES
* live in it. "Empty" therefore means nothing except those, and anything
* else counts as content.
*
* ⚠️ Deliberately not "are there any *.log files". Rotation and archives
* are postponed rather than dropped, and a rule written around one
* extension would call a directory holding a single archive empty and let
* it be deleted without a word. This rule survives file types it has never
* heard of.
*
* ⚠️ Three values rather than two, because the callers need different
* things from the same walk. The delete button wants the safe side of the
* error and counts what it could not read as content, so that it asks
* before removing. The ranking of leftovers and the reason given for a
* disabled Download button must not treat as content what nobody has
* seen. Two walks would put the
* rule "everything except the protective files" in two places, and they
* would part company the first time that list grows.
*
* A name that is not ours, or a directory that is not on disk, answers
* 'empty': there is nothing there to lose, and deleting a directory that
* is already gone is a success rather than a refusal.
*
* @param string $name Directory name, without the path.
* @return string One of 'empty', 'content', 'unreadable'.
*/
public static function content_state( $name ) {
$path = self::path( $name );
if ( '' === $path || ! is_dir( $path ) ) {
return 'empty';
}
/*
* Silenced deliberately. An unreadable directory makes scandir() print
* three warnings before it answers false, and false is what we act on.
* Printed, they would land in our own log file on every visit to the
* settings tab, where this runs once per row - a logger writing
* warnings into its own log. is_readable() is no substitute either: it
* lies on Windows ACLs the same way is_writable() does, which is why
* writability is checked by writing rather than by asking.
*/
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- The false answer is handled right below; the warning would only pollute our own log.
$entries = @scandir( $path );
if ( false === $entries ) {
return 'unreadable';
}
foreach ( $entries as $entry ) {
if ( '.' === $entry || '..' === $entry ) {
continue;
}
if ( ! in_array( $entry, self::PROTECTIVE_FILES, true ) ) {
return 'content';
}
}
return 'empty';
}
/**
* Whether a log directory holds anything besides the files we put there.
*
* The gate in front of deletion: false is removed without a question,
* anything else is asked about first.
*
* ⚠️ A directory that cannot be read comes back as holding content: the
* caller then asks before deleting, which is the safe way to be wrong.
* The three-way answer lives in content_state(); this is its gate, and the
* contract here is the one it has had since it was written.
*
* @param string $name Directory name, without the path.
* @return bool
*/
public static function has_content( $name ) {
return 'empty' !== self::content_state( $name );
}
/**
* Removes one log directory with everything inside it.
*
* ⚠️ Deleted directly rather than through WP_Filesystem, for the same
* reason the directory is created directly: the files in it were written by
* the PHP process itself, through error_log(), and belong to it. An FTP
* transport arrives as a different user who may not own them at all, and it
* would ask for credentials during an uninstall - where there is nobody
* left to ask.
*
* ⚠️ The name is checked before anything is touched. This is the one method
* in the plugin that deletes a whole tree, and it may only ever do so
* inside the plugin's folder in uploads, under our own prefix and an eight
* character suffix.
*
* @param string $name Directory name, without the path.
* @return true|WP_Error
*/
public static function delete( $name ) {
$name = (string) $name;
$path = self::path( $name );
if ( '' === $path ) {
return new WP_Error(
'dlogr_not_ours',
sprintf(
'%s is not a log directory of this plugin, so it was left alone.',
$name
)
);
}
if ( ! is_dir( $path ) ) {
return true;
}
$stuck = self::delete_tree( $path );
if ( '' !== $stuck ) {
/*
* ⚠️ Named from the log directory down, never from the root of the
* disk (заказчик, 2026-09-14): "dlogr-logs-a3f9c2e1/.htaccess" shows at
* once that the file sits at the top of the directory, and a table in
* the database has no business holding the layout of the server.
*
* delete_tree() builds every path it returns from $path, so the prefix
* is there by construction. It is checked all the same, and it is
* missing exactly when the directory itself came back - unreadable, or
* refusing rmdir() with nothing scandir() could list. Then there is no
* file to name, and the message says so rather than naming the
* directory twice.
*/
if ( 0 !== strpos( $stuck, $path . '/' ) ) {
return new WP_Error(
'dlogr_dir_not_deleted',
sprintf(
'The log directory %s could not be removed, and what is holding it could not be named.',
$name
)
);
}
return new WP_Error(
'dlogr_dir_not_deleted',
sprintf(
'The log directory %1$s could not be removed: %2$s stayed behind.',
$name,
$name . substr( $stuck, strlen( $path ) )
)
);
}
return true;
}
/**
* Restores the files that keep the directory private.
*
* Missing ones are written back without asking: they can be deleted
* separately, and there is nothing to lose or to think about. Existing
* files are left alone, README.txt among them, because it may carry a
* label written by a human.
*
* ⚠️ These three names are also listed in PROTECTIVE_FILES, which is what
* tells an empty directory from a full one. Adding a file here means adding
* it there too.
*
* The marker is not written here: protect() runs on leftovers as well, and
* a leftover must never carry it. That is mark()'s job.
*
* @param string $path Absolute path to a log directory.
* @return bool True when both protective files are in place.
*/
public static function protect( $path ) {
if ( '' === $path || ! is_dir( $path ) ) {
return false;
}
$index = self::put_missing( $path . '/index.html', self::index_contents() );
$htaccess = self::put_missing( $path . '/.htaccess', self::htaccess_contents() );
self::put_missing( $path . '/README.txt', self::readme_contents() );
return $index && $htaccess;
}
/**
* Makes sure the plugin's folder in uploads exists and is protected.
*
* For the loader data file, which is written on every settings save -
* the display mode among them, which works without a log directory. So
* the folder can be needed before any log directory is.
*
* @return bool True when the folder is there afterwards.
*/
public static function ensure_base() {
$base = self::base();
if ( '' === $base || ( ! is_dir( $base ) && ! wp_mkdir_p( $base ) ) ) {
return false;
}
self::protect_base();
return true;
}
/**
* Restores the files that keep the plugin's folder in uploads private.
*
* What they guard here is the listing: a listed folder would show the
* random names of the log directories. The loader data file next to them
* holds no secret and needs no guarding of its own (2026-10-11).
*
* @return bool True when both files are in place.
*/
private static function protect_base() {
$base = self::base();
if ( '' === $base || ! is_dir( $base ) ) {
return false;
}
$index = self::put_missing( $base . '/index.html', self::index_contents() );
$htaccess = self::put_missing( $base . '/.htaccess', self::htaccess_contents() );
return $index && $htaccess;
}
/**
* Marks a directory as the current one and takes the mark off all others.
*
* The loader picks the directory carrying the marker, so there must be
* exactly one. Two can still turn up - a directory copied by hand - and
* this is also what clears that: every ensure() ends here.
*
* @param string $name Directory name, without the path.
* @return bool False when the marker could not be written.
*/
private static function mark( $name ) {
$path = self::path( $name );
if ( '' === $path || ! self::put_missing( $path . '/' . self::MARKER_FILE, '' ) ) {
return false;
}
foreach ( self::find_existing() as $other ) {
if ( $other === $name ) {
continue;
}
$file = self::path( $other ) . '/' . self::MARKER_FILE;
if ( file_exists( $file ) ) {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions -- Silenced as in probe_write(): a marker that refuses to go is met again on the next ensure(). See the class docblock for why this is not WP_Filesystem.
@unlink( $file );
}
}
return true;
}
/**
* Removes the plugin's folder in uploads once no log directory is left in it.
*
* For uninstall.php. Only the files this plugin puts there are deleted;
* anything else found in the folder keeps it, untouched, rather than being
* taken along by a folder nobody asked to empty.
*
* @return bool True when the folder is gone or was never there.
*/
public static function remove_base() {
$base = self::base();
if ( '' === $base || ! is_dir( $base ) ) {
return true;
}
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Same reason as in content_state(): the warning would land in our own log.
$entries = @scandir( $base );
if ( false === $entries ) {
return false;
}
$ours = array( 'index.html', '.htaccess', self::DATA_FILE );
if ( ! empty( array_diff( $entries, array_merge( array( '.', '..' ), $ours ) ) ) ) {
return false;
}
foreach ( $ours as $file ) {
if ( file_exists( $base . '/' . $file ) ) {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions -- See delete_tree().
@unlink( $base . '/' . $file );
}
}
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions -- See delete_tree().
return @rmdir( $base );
}
/**
* Absolute path of a log directory.
*
* @param string $name Directory name. Defaults to the active one.
* @return string Empty string when there is no name, or the name is not ours.
*/
public static function path( $name = '' ) {
if ( '' === $name ) {
$name = self::name();
}
$base = self::base();
if ( '' === $base || ! self::is_valid_name( $name ) ) {
return '';
}
return $base . '/' . $name;
}
/**
* Absolute path of the plugin's folder in uploads.
*
* Worked out from wp_upload_dir() on every call rather than stored or
* built from constants (2026-10-11): where uploads lives is decided by the
* upload_path option, the UPLOADS constant and the upload_dir filter
* together, and the core function is the one place that knows all three.
* It caches its answer, so asking again costs nothing. The false keeps it
* from creating this month's folder as a side effect.
*
* @return string Empty string when WordPress names no uploads folder.
*/
public static function base() {
$uploads = wp_upload_dir( null, false );
if ( empty( $uploads['basedir'] ) ) {
return '';
}
return untrailingslashit( wp_normalize_path( $uploads['basedir'] ) ) . '/' . self::BASE_NAME;
}
/**
* Short readable form of a path in the plugin's folder, for messages.
*
* Starts at the uploads folder, the way the directory is pointed at in
* words, and never at the root of the disk: a text in the journal has no
* business holding the layout of the server (заказчик, 2026-09-14).
*
* @param string $name Directory name, or empty for the folder itself.
* @return string
*/
public static function label( $name = '' ) {
$base = self::base();
$label = '' === $base ? self::BASE_NAME : basename( dirname( $base ) ) . '/' . self::BASE_NAME;
return '' === $name ? $label : $label . '/' . $name;
}
/**
* Name of the active log directory.
*
* @return string Empty string when none was chosen yet.
*/
public static function name() {
return (string) DLOGR_Settings::get_state( 'log_dir' );
}
/**
* Whether the active log directory is on disk.
*
* @return bool
*/
public static function exists() {
$path = self::path();
return '' !== $path && is_dir( $path );
}
/**
* Whether the active log directory exists and accepts writes.
*
* This is the gate behind the "enable logging" button: neither boot-data.json
* nor the block in wp-config.php is written while the directory is missing
* or refuses writes - WP_DEBUG_LOG must never point somewhere unwritable.
* Activation does not come here at all; it touches no disk.
*
* @return bool
*/
public static function is_usable() {
return self::exists() && self::probe_write( self::path() );
}
/**
* The directory logging goes to, or will go to once it is switched on.
*
* The stored one when there is one. Otherwise the leftover ensure() would
* take over, so that the settings tab shows before enabling exactly the
* directory enabling will use - one ranking, not two guesses at it.
*
* Only a leftover that looks writable is offered (dorabotki.md, пункт 21):
* one that is not would be passed over by ensure(), and with none left a
* new directory is created - so the answer is then "none yet", the same as
* on an empty disk. The two can still part where is_writable() is wrong,
* on Windows ACLs: ensure() then moves on to the next, or makes a new one.
*
* @return string Directory name, empty when there is nothing to take over.
*/
public static function current() {
$name = self::name();
if ( '' !== $name ) {
return $name;
}
foreach ( self::ranked() as $candidate ) {
if ( self::looks_writable( $candidate ) ) {
return $candidate;
}
}
return '';
}
/**
* Directories found in the plugin's folder besides the current one.
*
* Left by earlier installs and not taken over. Nothing is written to them;
* they are listed so they can be downloaded, renamed or deleted, and each
* gets a box of its own among the uninstall choices.
*
* @return array Directory names, without the path.
*/
public static function leftovers() {
return array_values( array_diff( self::find_existing(), array( self::current() ) ) );
}
/**
* Leftover directories in the order ensure() takes them over.
*
* Directories holding logs first, and among them the one written to last:
* that is the log a person most likely wants to go on with (заказчик,
* 2026-09-28). Then the empty ones, in the order they were found - with
* nothing in them, any is as good as another. Unreadable ones go last:
* what they hold nobody has seen, and a directory PHP cannot list is a
* poor bet for one it has to write into.
*
* ⚠️ Over all of that, the ones PHP does not seem able to write to go last
* (dorabotki.md, пункт 21). current() shows the first writable one of this
* list and ensure() starts from the top of it, so the page names before
* enabling the directory enabling will take. Asked with is_writable(),
* which only reads: drawing a page writes nothing (2026-09-04). It is a
* hint, not the decision - it misses Windows ACLs, and adopt() still has
* the last word with a write of its own.
*
* @return array Directory names, without the path.
*/
public static function ranked() {
$content = array();
$empty = array();
$unreadable = array();
foreach ( self::find_existing() as $name ) {
$state = self::content_state( $name );
if ( 'content' === $state ) {
$content[ $name ] = self::last_written( $name );
} elseif ( 'empty' === $state ) {
$empty[] = $name;
} else {
$unreadable[] = $name;
}
}
// Names always start with the prefix, so array keys stay strings.
arsort( $content );
$writable = array();
$refusing = array();
foreach ( array_merge( array_keys( $content ), $empty, $unreadable ) as $name ) {
if ( self::looks_writable( $name ) ) {
$writable[] = $name;
} else {
$refusing[] = $name;
}
}
return array_merge( $writable, $refusing );
}
/**
* Journal text for a directory ensure() has just created.
*
* One sentence for the two callers that write it - the enable path and the
* repair - so they cannot drift apart. With leftovers on disk a new
* directory means every one of them refused writes, and that is said with
* their names: a year later two directories side by side need explaining.
*
* @param array $found Directories found on disk before ensure() ran.
* @return string
*/
public static function created_message( array $found ) {
if ( empty( $found ) ) {
return sprintf( 'Log directory %s is ready.', self::name() );
}
return sprintf(
'Log directory %1$s is ready. None of the directories left by earlier installs could be written to, so none was taken over: %2$s.',
self::name(),
implode( ', ', $found )
);
}
/**
* Whether PHP appears to be allowed to write into a log directory.
*
* For ordering and for what the page says, never for the decision to
* write: that is probe_write(), which writes.
*
* @param string $name Directory name, without the path.
* @return bool
*/
public static function looks_writable( $name ) {
$path = self::path( $name );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- A hint that only reads, see above; wp_is_writable() on Windows writes a test file, which a page that only reports state must not do.
return '' !== $path && is_writable( $path );
}
/**
* Log directories of this plugin found in its folder in uploads.
*
* Leftovers from earlier installs are taken over by ensure() rather than
* left lying next to a new directory, and any beyond the one taken over
* are listed on the settings tab so they can be downloaded or deleted.
*
* @return array Directory names, without the path.
*/
public static function find_existing() {
$base = self::base();
if ( '' === $base ) {
return array();
}
$pattern = $base . '/' . self::PREFIX . str_repeat( '?', self::SUFFIX_LENGTH );
$found = glob( $pattern, GLOB_ONLYDIR );
if ( ! is_array( $found ) ) {
return array();
}
$names = array();
foreach ( $found as $path ) {
$name = basename( $path );
if ( self::is_valid_name( $name ) ) {
$names[] = $name;
}
}
return $names;
}
/**
* A fresh random name.
*
* The random part is the primary protection: the directory is served by the
* web server, and a name nobody can guess is what keeps the logs out of
* reach. The .htaccess file is the second layer, not the first.
*
* wp_generate_password() returns mixed case and runs through the
* random_password filter, which any plugin may replace. Both are filtered
* down to a-z0-9 here: mixed case is a trap on case-insensitive file
* systems, and a name built from whatever a foreign filter returned has no
* business being a directory. That still leaves 36^8 names.
*
* @return string Empty string when no usable name could be built.
*/
private static function generate_name() {
$suffix = '';
$length = 0;
for ( $attempt = 0; $attempt < 5; $attempt++ ) {
if ( $length >= self::SUFFIX_LENGTH ) {
break;
}
$suffix .= preg_replace( '/[^a-z0-9]/', '', strtolower( wp_generate_password( self::SUFFIX_LENGTH, false, false ) ) );
$length = strlen( $suffix );
}
if ( $length < self::SUFFIX_LENGTH ) {
return '';
}
return self::PREFIX . substr( $suffix, 0, self::SUFFIX_LENGTH );
}
/**
* Whether a name is one of ours.
*
* Also the traversal guard: every path this class builds goes through here,
* so a name coming back from the database or from a form cannot climb out
* of the plugin's folder. Public for the settings layer, which keeps a list of names
* that uninstall.php will delete.
*
* @param string $name Directory name.
* @return bool
*/
public static function is_valid_name( $name ) {
$pattern = '/^' . preg_quote( self::PREFIX, '/' ) . '[a-z0-9]{' . self::SUFFIX_LENGTH . '}$/';
return (bool) preg_match( $pattern, (string) $name );
}
/**
* When anything in a log directory was last written to.
*
* The newest modification time among the entries, the protective files
* aside: they are written once, when the directory is made, and would make
* every directory look as fresh as its birth. Only the top level is looked
* at, which is where logging writes.
*
* @param string $name Directory name, without the path.
* @return int Unix time, 0 when nothing could be read.
*/
private static function last_written( $name ) {
$path = self::path( $name );
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Same reason as in content_state(): the warning would land in our own log.
$entries = '' === $path ? false : @scandir( $path );
if ( false === $entries ) {
return 0;
}
$newest = 0;
foreach ( $entries as $entry ) {
if ( '.' === $entry || '..' === $entry || in_array( $entry, self::PROTECTIVE_FILES, true ) ) {
continue;
}
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A file gone between scandir() and here answers false, which is skipped.
$time = @filemtime( $path . '/' . $entry );
if ( false !== $time && $time > $newest ) {
$newest = $time;
}
}
return $newest;
}
/**
* Checks writability by writing, not by asking.
*
* The is_writable() answer lies on Windows ACLs and under some open_basedir
* setups, and the price of believing it is logs that silently never appear.
*
* @param string $path Absolute path to a log directory.
* @return bool
*/
private static function probe_write( $path ) {
$file = $path . '/' . self::PROBE_FILE;
/*
* Silenced for the reason given in content_state(): a refused write
* raises a warning, and false is what we act on. A leftover that refuses
* it is passed over for the next one, so the warning would only be noise
* in a log - our own, once the loader has redirected it (dorabotki.md,
* пункт 23).
*/
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions -- See above; the log directory has to be writable by the PHP process itself, WP_Filesystem would test the wrong thing.
if ( false === @file_put_contents( $file, 'x' ) ) {
return false;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions -- Same reason: this is the file we have just written ourselves.
unlink( $file );
return true;
}
/**
* Removes a directory and everything under it.
*
* Stops at the first entry that resists instead of carrying on: a
* half-emptied directory is a leftover just the same as a full one, and
* deleting the rest of somebody's logs once we already know we cannot
* finish buys nothing at all.
*
* Links and Windows junctions are removed, never followed: whatever they
* point at is not ours. unlink() is tried first and rmdir() second, because
* a junction yields only to the latter.
*
* @param string $path Absolute path, already established to be one of ours.
* @return string Empty string on success, otherwise the path that stayed.
*/
private static function delete_tree( $path ) {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Same reason as in content_state(); an unreadable directory comes back as the path that stayed.
$entries = @scandir( $path );
if ( false === $entries ) {
return $path;
}
foreach ( $entries as $entry ) {
if ( '.' === $entry || '..' === $entry ) {
continue;
}
$full = $path . '/' . $entry;
if ( is_link( $full ) ) {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions -- Silenced for the reason given below; see the class docblock for why this is not WP_Filesystem.
$gone = @unlink( $full ) || @rmdir( $full );
} elseif ( is_dir( $full ) ) {
$stuck = self::delete_tree( $full );
if ( '' !== $stuck ) {
return $stuck;
}
$gone = true;
} else {
/*
* Silenced on purpose: a refused unlink() raises a warning, and
* a warning raised here would be written into the very log file
* this call is trying to delete. The failure is not lost - it
* comes back as the path that stayed.
*/
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions -- See above.
$gone = @unlink( $full );
}
if ( ! $gone ) {
return $full;
}
}
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions -- See above.
if ( @rmdir( $path ) ) {
return '';
}
/*
* ⚠️ Found by the isolated run on 2026-09-05, and it is the everyday
* case rather than an exotic one: a log file another PHP process still
* has open. On Windows unlink() then reports success, the entry stays
* until the last handle closes, and it is this rmdir() that fails
* instead. Naming the directory would be true and useless, so whatever
* is still inside it gets named.
*/
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Same reason as in content_state(); unreadable, the directory itself is named.
$left = @scandir( $path );
if ( is_array( $left ) ) {
$left = array_diff( $left, array( '.', '..' ) );
if ( ! empty( $left ) ) {
return $path . '/' . reset( $left );
}
}
return $path;
}
/**
* Writes a file only if it is not there yet.
*
* @param string $file Absolute path.
* @param string $contents What to write.
* @return bool True when the file is in place afterwards.
*/
private static function put_missing( $file, $contents ) {
if ( file_exists( $file ) ) {
return true;
}
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions -- Silenced as in probe_write(): the failure comes back as false. See the class docblock for why this is not WP_Filesystem.
return false !== @file_put_contents( $file, $contents );
}
/**
* Contents of index.html.
*
* Empty, the way WooCommerce leaves its own in uploads/wc-logs. It only
* hides the listing; against a direct link to a file it does nothing.
*
* @return string
*/
private static function index_contents() {
return '';
}
/**
* Contents of .htaccess.
*
* Two branches because the Apache syntax changed: Require is 2.4, Deny is
* 2.2. Asking IfModule about the module is more reliable than reading a
* version out of SERVER_SOFTWARE, which is often not there at all.
*
* Options -Indexes is left out on purpose: denying access already hides the
* listing, and Options under AllowOverride Limit answers with a 500.
*
* @return string
*/
private static function htaccess_contents() {
return "# Debug Log Router: logs are private.\n"
. "\n"
. "\tRequire all denied\n"
. "\n"
. "\n"
. "\tOrder allow,deny\n"
. "\tDeny from all\n"
. "\n";
}
/**
* Contents of README.txt.
*
* Two years later, over FTP, this is the only thing telling whoever finds
* the directory what it is and whether it can be deleted. No secrets in it:
* the plugin name and a date.
*
* @return string
*/
private static function readme_contents() {
return "Debug Log Router - WordPress plugin log directory\n"
. 'Created: ' . gmdate( 'Y-m-d H:i' ) . " UTC\n"
. "\n"
. "The directory name is random on purpose: it keeps the logs from being\n"
. "found by guessing the URL. Safe to delete if the plugin is gone.\n";
}
/**
* The error used whenever the probe write fails.
*
* @param string $name Directory name, empty when the name is not stored
* anywhere and naming it would mislead - see create().
* @return WP_Error
*/
private static function not_writable_error( $name ) {
if ( '' === $name ) {
return new WP_Error(
'dlogr_not_writable',
sprintf(
'A log directory in %s cannot be written to. The web server may not be allowed to write there, or the disk may be full.',
self::label()
)
);
}
return new WP_Error(
'dlogr_not_writable',
sprintf(
'The log directory %s cannot be written to. The web server may not be allowed to write there, or the disk may be full. Check again once that is fixed.',
$name
)
);
}
}