capability() ) ) { return; } $action = Request::key( 'action' ); if ( ! in_array( $action, array( 'cleanup_uploads', 'download_log', 'clear_logs' ), true ) ) { return; } SecurityManager::verify_admin_request( self::NONCE_ACTION, Capabilities::USE_TOOLS ); if ( 'download_log' === $action ) { $this->download_log(); return; } if ( 'clear_logs' === $action ) { $this->redirect( 'logs_cleared', $this->logger->store()->clear() ); } $this->redirect( 'cleaned', $this->cleaner->run() ); } /** * Sends one log file to the browser. * * The name is resolved against the files that exist rather than joined onto * the directory, so a crafted name cannot be used to read something else. */ private function download_log(): void { $store = $this->logger->store(); $name = self::log_name_at( $store->files(), Request::int( 'file' ) ); $path = '' === $name ? '' : $store->path( $name ); if ( '' === $path ) { $this->redirect( 'log_missing', 0 ); } // Stops on the first buffer that refuses to be removed: `ob_end_clean()` // returns false without lowering the level for those, and looping on the // level alone never ends. while ( ob_get_level() > 0 ) { if ( ! ob_end_clean() ) { break; } } /* * Bytes are already on their way, so the headers below would be refused * and the log would be printed into the page instead of downloaded. * Leave the screen to render normally rather than do that. */ if ( headers_sent() ) { return; } nocache_headers(); header( 'Content-Type: text/plain; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="' . $name . '"' ); header( 'Content-Length: ' . (int) filesize( $path ) ); header( 'X-Content-Type-Options: nosniff' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile, WordPress.WP.AlternativeFunctions.file_system_read_readfile -- Streaming a file this plugin wrote, from its own directory. readfile( $path ); exit; } /** * Sends the browser back to a clean tools URL. * * @param string $message Message key describing the outcome. * @param int $count Number of affected items. */ private function redirect( string $message, int $count ): void { wp_safe_redirect( $this->url( array( FormsPage::MESSAGE_ARG => $message, FormsPage::COUNT_ARG => $count, ) ) ); exit; } /** * Returns the view path. */ protected function view(): string { return 'admin/tools/general'; } /** * Returns the data passed to the view. * * @return array Variables exposed to the template. */ protected function data(): array { return array( 'page_title' => $this->page_title(), 'nonce_action' => self::NONCE_ACTION, 'action_url' => $this->url(), 'db_version' => Installer::installed_version(), 'missing_tables' => Installer::missing_tables(), 'upload_dir' => UploadManager::base_dir(), 'orphan_count' => $this->cleaner->count_orphans(), 'cleanup_url' => wp_nonce_url( $this->url( array( 'action' => 'cleanup_uploads' ) ), self::NONCE_ACTION ), 'log_level' => $this->logger->threshold(), 'log_levels' => LogLevel::choices(), 'log_files' => $this->log_files(), 'log_size' => $this->logger->store()->size(), 'log_preview' => $this->log_preview(), 'clear_logs_url' => wp_nonce_url( $this->url( array( 'action' => 'clear_logs' ) ), self::NONCE_ACTION ), 'notice' => $this->notice(), ); } /** * Returns the stored log files with their download links. * * @return array> The files, newest first. */ private function log_files(): array { $files = array(); // The position is the identifier: `$index` was never assigned, so every // Download and View link fell back to the newest file. foreach ( $this->logger->store()->files() as $index => $file ) { $files[] = array( 'name' => $file['name'], /* * A label the screen can print. The filename carries a salt-derived * token that, on a server ignoring the log directory's `.htaccess`, * is the only thing keeping the file private — so it belongs in the * links, which are nonce-gated, and not in the page text. */ 'label' => self::log_label( (string) $file['name'] ), 'size' => $file['size'], 'modified' => $file['modified'], 'url' => wp_nonce_url( $this->url( array( 'action' => 'download_log', 'file' => $index, ) ), self::NONCE_ACTION ), // Identified by position, never by name: the name carries the token that // is the only access control on a server ignoring the directory guards. 'view_url' => $this->url( array( 'log' => $index ) ), ); } return $files; } /** * Turns a log filename into something safe to print. * * Files are named `formpilot-YYYY-MM-DD[-rotation]-.log`. The date and the * rotation counter identify the file for a human; the token does not, and is * load-bearing. * * @param string $name File name as stored. * * @return string A label identifying the file without disclosing its name. */ private static function log_label( string $name ): string { if ( 1 === preg_match( '/^formpilot-(\d{4}-\d{2}-\d{2})(?:-(\d+))?-[0-9a-f]+\.log$/', $name, $matches ) ) { return '' === ( $matches[2] ?? '' ) ? $matches[1] : sprintf( /* translators: 1: Date of the log file. 2: Rotation number. */ __( '%1$s (part %2$s)', 'devplugins-custom-form-builder' ), $matches[1], $matches[2] ); } return __( 'Log file', 'devplugins-custom-form-builder' ); } /** * Returns the tail of the file being looked at. * * @return array{name: string, lines: array} The preview. */ private function log_preview(): array { $files = $this->logger->store()->files(); // Nothing chosen shows the newest, which is what somebody who came here // to find out what just went wrong is looking for. $name = self::log_name_at( $files, Request::int( 'log' ) ); return array( 'name' => $name, // What the screen may print. The name itself must not be shown. 'label' => '' === $name ? '' : self::log_label( $name ), 'lines' => '' === $name ? array() : $this->logger->store()->tail( $name, 200 ), ); } /** * Resolves a log file's position in the listing to its name. * * Files are addressed by position rather than by name everywhere a request or * a link can carry the reference, because the name contains a salt-derived * token which, on a server that ignores the directory's guard files, is the * only thing keeping the file from being fetched by anyone who knows it. * Putting it in an `href` published it on the page. * * An out-of-range position resolves to the newest file, which is what an * operator arriving with no preference wants. * * @param array> $files Listing, newest first. * @param int $index Requested position. * * @return string The file name, or an empty string when there are none. */ private static function log_name_at( array $files, int $index ): string { $index = isset( $files[ $index ] ) ? $index : 0; return isset( $files[ $index ]['name'] ) ? (string) $files[ $index ]['name'] : ''; } /** * Builds the notice shown after a tool ran. * * @return array{type: string, text: string}|null The notice, or null when there is nothing to report. */ private function notice(): ?array { $message = Request::key( FormsPage::MESSAGE_ARG ); $count = max( 0, Request::int( FormsPage::COUNT_ARG ) ); return match ( $message ) { 'cleaned' => array( 'type' => 'success', 'text' => sprintf( /* translators: %s: Number of deleted files. */ _n( '%s orphaned file deleted.', '%s orphaned files deleted.', $count, 'devplugins-custom-form-builder' ), number_format_i18n( $count ) ), ), 'logs_cleared' => array( 'type' => 'success', 'text' => sprintf( /* translators: %s: Number of deleted log files. */ _n( '%s log file deleted.', '%s log files deleted.', $count, 'devplugins-custom-form-builder' ), number_format_i18n( $count ) ), ), 'log_missing' => array( 'type' => 'error', 'text' => __( 'That log file no longer exists.', 'devplugins-custom-form-builder' ), ), default => null, }; } }