/prweb-private/api_debug.json` with secrets scrubbed. Pulled * out of Prweb_Pds_Client so the transport class stays under the 500-line cap. * * The directory name carries the brand prefix because the uploads folder is * shared with every other plugin on the install — the same reason option keys * and script handles do. It shipped as `webapp-private` until ORL-229; the * migration in includes/migrations/ renames it forward and uninstall.php * removes every spelling. * * @package Prweb */ declare(strict_types=1); defined( 'ABSPATH' ) || exit; class Prweb_Pds_Debug_Log { /** Suffix appended to the brand's storage prefix, e.g. `prweb-private`. */ private const LOG_DIR_SUFFIX = '-private'; private const LOG_FILE = 'api_debug.json'; private const KEEP_ENTRIES = 10; private const SECRET_KEYS = [ 'ownerPassword', 'sellerPassword', 'buyerPassword', 'ownerCredentials', 'sellerCredentials', 'buyerCredentials', 'userCredentials', 'senderCredentials', 'funderCredentials', 'prvKey', 'privateKey', 'prv_key', 'access_token', 'refresh_token', 'code', 'code_verifier', 'client_secret', ]; /** * Directory name under ``, current spelling. * Derived from the generated storage prefix so a future prefix move takes * this with it rather than leaving a stale folder in every publisher's * uploads. ORL-213 missed it precisely because it is filesystem state. */ public static function log_dir_name(): string { return rtrim( Prweb_Brand::OPTION_PREFIX, '_' ) . self::LOG_DIR_SUFFIX; } /** Every spelling this directory has shipped under, current first. */ public static function log_dir_candidates(): array { $names = [ self::log_dir_name() ]; foreach ( Prweb_Brand::LEGACY_OPTION_PREFIXES as $legacy ) { $names[] = rtrim( $legacy, '_' ) . self::LOG_DIR_SUFFIX; } return array_values( array_unique( $names ) ); } public static function record( string $endpoint, array $args, mixed $response ): void { $upload = wp_upload_dir(); $log_dir = trailingslashit( $upload['basedir'] ) . self::log_dir_name(); $log_file = $log_dir . '/' . self::LOG_FILE; if ( ! file_exists( $log_dir ) ) { wp_mkdir_p( $log_dir ); @file_put_contents( $log_dir . '/.htaccess', "Require all denied\n" ); @file_put_contents( $log_dir . '/index.php', " gmdate( 'Y-m-d H:i:s' ), 'endpoint' => $endpoint, 'method' => $args['method'], 'headers' => self::redact_headers( $args['headers'] ?? [] ), 'request' => self::redact( $args['body'] ?? null ), 'response' => self::redact( $response ), ]; $log = []; if ( file_exists( $log_file ) ) { $log = json_decode( (string) file_get_contents( $log_file ), true ); if ( ! is_array( $log ) ) { $log = []; } } array_unshift( $log, $entry ); $log = array_slice( $log, 0, self::KEEP_ENTRIES ); @file_put_contents( $log_file, json_encode( $log, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); } /** Redact secret-looking fields recursively so debug logs are safe to share. */ public static function redact( mixed $data ): mixed { if ( is_string( $data ) ) { $decoded = json_decode( $data, true ); if ( is_array( $decoded ) ) { return self::redact_array( $decoded ); } return $data; } if ( is_array( $data ) ) { return self::redact_array( $data ); } if ( is_object( $data ) ) { return self::redact_array( (array) $data ); } return $data; } private static function redact_array( array $data ): array { foreach ( $data as $k => $v ) { if ( is_string( $k ) && strcasecmp( $k, 'Authorization' ) === 0 && is_string( $v ) ) { $data[ $k ] = '[REDACTED]'; continue; } if ( in_array( $k, self::SECRET_KEYS, true ) && is_string( $v ) ) { $data[ $k ] = '«redacted»'; } elseif ( is_array( $v ) ) { $data[ $k ] = self::redact_array( $v ); } elseif ( is_object( $v ) ) { $data[ $k ] = self::redact_array( (array) $v ); } } return $data; } /** Header redaction: scrub Authorization (any case) before logging. */ public static function redact_headers( array $headers ): array { foreach ( $headers as $k => $v ) { if ( is_string( $k ) && strcasecmp( $k, 'Authorization' ) === 0 && is_string( $v ) ) { $headers[ $k ] = '[REDACTED]'; } } return $headers; } }