$matches[1], 'repo' => $matches[2], ); } return new WP_Error( 'invalid_format', __( 'Could not parse GitHub URL', 'robotstxt-documentation-markdown' ) ); } /** * Validate and normalize a list of file data from cache or API response * * Converts untyped cache data back into the expected typed structure. * Required for PHPStan level 9 compliance with transient cache usage. * * @since 1.0.1 * * @param mixed $data Raw data to validate (typically from get_transient()). * @return list */ function robotstxt_docmd_validate_file_list( mixed $data ): array { if ( ! is_array( $data ) ) { return array(); } $result = array(); foreach ( $data as $item ) { if ( is_array( $item ) && isset( $item['path'], $item['name'], $item['size'], $item['sha'] ) && is_string( $item['path'] ) && is_string( $item['name'] ) && is_int( $item['size'] ) && is_string( $item['sha'] ) ) { $result[] = array( 'path' => $item['path'], 'name' => $item['name'], 'size' => $item['size'], 'sha' => $item['sha'], ); } } return $result; } /** * Get all markdown files from repository * * @since 1.0.0 * * @param string $owner Repository owner. * @param string $repo Repository name. * @param string $branch Branch name. * @param string $token GitHub token (encrypted). * @return list|WP_Error Array of files or error. */ function robotstxt_docmd_get_all_markdown_files( string $owner, string $repo, string $branch, string $token ): array|WP_Error { // Check cache first. $cache_key = sprintf( 'robotstxt_docmd_files_%s_%s_%s', $owner, $repo, $branch ); $cached = get_transient( $cache_key ); if ( false !== $cached ) { return robotstxt_docmd_validate_file_list( $cached ); } // Decrypt token. $decrypted_token = robotstxt_docmd_decrypt_token( $token ); // Get files recursively. $files = robotstxt_docmd_get_repository_contents_recursive( $owner, $repo, '', $branch, $decrypted_token ); if ( is_wp_error( $files ) ) { return $files; } // Filter only markdown files and re-index. $markdown_files = array(); foreach ( $files as $file ) { if ( preg_match( '/\.(md|markdown)$/i', $file['path'] ) ) { $markdown_files[] = $file; } } // Cache for 24 hours. set_transient( $cache_key, $markdown_files, DAY_IN_SECONDS ); return $markdown_files; } /** * Get repository contents recursively * * @since 1.0.0 * * @param string $owner Repository owner. * @param string $repo Repository name. * @param string $path Path in repository. * @param string $branch Branch name. * @param string $token GitHub token (plain). * @param int $depth Current depth (for recursion limit). * @return list|WP_Error Array of files or error. */ function robotstxt_docmd_get_repository_contents_recursive( string $owner, string $repo, string $path, string $branch, string $token, int $depth = 0 ): array|WP_Error { // Prevent infinite recursion. if ( $depth > 10 ) { return array(); } $url = sprintf( 'https://api.github.com/repos/%s/%s/contents/%s?ref=%s', rawurlencode( $owner ), rawurlencode( $repo ), rawurlencode( $path ), rawurlencode( $branch ) ); $response = wp_remote_get( $url, array( 'headers' => array( 'Authorization' => 'Bearer ' . $token, 'Accept' => 'application/vnd.github.v3+json', 'User-Agent' => 'WordPress-Documentation-Plugin', ), 'timeout' => 30, ) ); if ( is_wp_error( $response ) ) { return $response; } $code = wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) { return new WP_Error( 'github_api_error', sprintf( /* translators: %d: HTTP response code */ __( 'GitHub API error: %d', 'robotstxt-documentation-markdown' ), $code ) ); } $body = wp_remote_retrieve_body( $response ); $contents = json_decode( $body, true ); if ( ! is_array( $contents ) ) { return new WP_Error( 'invalid_response', __( 'Invalid GitHub API response', 'robotstxt-documentation-markdown' ) ); } $files = array(); foreach ( $contents as $item ) { if ( ! is_array( $item ) || ! isset( $item['type'], $item['path'], $item['name'], $item['size'], $item['sha'] ) || ! is_string( $item['type'] ) || ! is_string( $item['path'] ) || ! is_string( $item['name'] ) || ! is_string( $item['sha'] ) || ! is_int( $item['size'] ) ) { continue; } if ( 'file' === $item['type'] ) { // Skip files larger than 10MB. if ( $item['size'] > 10 * 1024 * 1024 ) { continue; } $files[] = array( 'path' => $item['path'], 'name' => $item['name'], 'size' => $item['size'], 'sha' => $item['sha'], ); } elseif ( 'dir' === $item['type'] ) { // Recurse into directory. $subfiles = robotstxt_docmd_get_repository_contents_recursive( $owner, $repo, $item['path'], $branch, $token, $depth + 1 ); if ( ! is_wp_error( $subfiles ) ) { $files = array_merge( $files, $subfiles ); } } } return $files; } /** * Get file content from GitHub * * @since 1.0.0 * * @param string $owner Repository owner. * @param string $repo Repository name. * @param string $file_path File path. * @param string $branch Branch name. * @param string $token GitHub token (encrypted). * @return string|WP_Error File content or error. */ function robotstxt_docmd_get_file_content( string $owner, string $repo, string $file_path, string $branch, string $token ): string|WP_Error { $decrypted_token = robotstxt_docmd_decrypt_token( $token ); $url = sprintf( 'https://api.github.com/repos/%s/%s/contents/%s?ref=%s', rawurlencode( $owner ), rawurlencode( $repo ), rawurlencode( $file_path ), rawurlencode( $branch ) ); $response = wp_remote_get( $url, array( 'headers' => array( 'Authorization' => 'Bearer ' . $decrypted_token, 'Accept' => 'application/vnd.github.v3+json', 'User-Agent' => 'WordPress-Documentation-Plugin', ), 'timeout' => 30, ) ); if ( is_wp_error( $response ) ) { return $response; } $code = wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) { return new WP_Error( 'github_api_error', sprintf( /* translators: %d: HTTP response code */ __( 'GitHub API error: %d', 'robotstxt-documentation-markdown' ), $code ) ); } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( ! is_array( $data ) || ! isset( $data['content'], $data['encoding'] ) || ! is_string( $data['content'] ) || ! is_string( $data['encoding'] ) ) { return new WP_Error( 'invalid_response', __( 'Invalid file response from GitHub', 'robotstxt-documentation-markdown' ) ); } // Decode content (GitHub returns base64). if ( 'base64' === $data['encoding'] ) { $decoded = base64_decode( $data['content'], true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode if ( false === $decoded ) { return new WP_Error( 'decode_failed', __( 'Failed to decode file content from GitHub.', 'robotstxt-documentation-markdown' ) ); } return $decoded; } return $data['content']; }