robotstxt-documentation-mar.../robotstxt-documentation-markdown-github.php
2026-03-28 15:38:41 +00:00

293 lines
7.5 KiB
PHP

<?php
/**
* GitHub API Functions
*
* @package RobotsTxt\DocumentationMarkdown
* @since 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Parse GitHub URL
*
* @since 1.0.0
*
* @param string $url GitHub repository URL.
* @return array{owner: string, repo: string}|WP_Error Array with 'owner' and 'repo' or error.
*/
function robotstxt_docmd_parse_github_url( string $url ): array|WP_Error {
if ( ! str_starts_with( $url, 'https://' ) ) {
return new WP_Error( 'invalid_url', __( 'GitHub URL must use HTTPS', 'robotstxt-documentation-markdown' ) );
}
if ( preg_match( '/github\.com\/([a-zA-Z0-9_-]+)\/([a-zA-Z0-9._-]+?)(?:\.git)?\/?$/', $url, $matches ) ) {
return array(
'owner' => $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<array{path: string, name: string, size: int, sha: string}>
*/
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<array{path: string, name: string, size: int, sha: string}>|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<array{path: string, name: string, size: int, sha: string}>|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',
$owner,
$repo,
$path,
$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',
$owner,
$repo,
$file_path,
$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'] ) {
return base64_decode( $data['content'] ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
}
return $data['content'];
}