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 ) ); } }