robotstxt-documentation-mar.../robotstxt-documentation-markdown-functions.php
2026-08-08 08:53:08 +00:00

263 lines
7.7 KiB
PHP

<?php
/**
* General Helper Functions
*
* @package RobotsTxt\DocumentationMarkdown
* @since 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Derive the AES-256-CBC key for token encryption using HKDF-SHA256.
*
* Uses wp_salt('auth') as input keying material and derives a 32-byte key
* scoped to token encryption. Replaces the <= 1.1.1 practice of feeding
* wp_salt('auth') directly to openssl_encrypt().
*
* @since 1.2.0
*
* @return string 32 raw key bytes.
*/
function robotstxt_docmd_token_key(): string {
return hash_hkdf( 'sha256', wp_salt( 'auth' ), 32, 'robotstxt-docmd-github-token' );
}
/**
* Encrypt GitHub token for storage
*
* Stored format: `v2:` followed by base64( IV || ciphertext ), encrypted with
* the HKDF-derived key. The `v2:` prefix distinguishes v2 tokens from the
* legacy (<= 1.1.1) format written by earlier versions.
*
* @since 1.0.0
*
* @param string $token Plain token.
* @return string Encrypted token (v2-prefixed), or empty string on failure.
*/
function robotstxt_docmd_encrypt_token( string $token ): string {
if ( '' === $token ) {
return '';
}
$key = robotstxt_docmd_token_key();
$iv_length = openssl_cipher_iv_length( 'aes-256-cbc' );
$iv = openssl_random_pseudo_bytes( $iv_length );
$encrypted = openssl_encrypt( $token, 'aes-256-cbc', $key, 0, $iv );
if ( false === $encrypted ) {
return '';
}
return 'v2:' . base64_encode( $iv . $encrypted ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
}
/**
* Decrypt GitHub token from storage
*
* Handles two storage formats:
* - `v2:`-prefixed tokens are decrypted with the HKDF-derived key.
* - Legacy (unprefixed) tokens are decrypted with wp_salt('auth') directly,
* then transparently re-encrypted with the v2 scheme and persisted (one-time
* lazy migration). This keeps the cron path working without an admin request.
*
* @since 1.0.0
*
* @param string $encrypted_token Encrypted token (v2 or legacy).
* @return string Plain token, or empty string on failure.
*/
function robotstxt_docmd_decrypt_token( string $encrypted_token ): string {
if ( '' === $encrypted_token ) {
return '';
}
$iv_length = openssl_cipher_iv_length( 'aes-256-cbc' );
// v2 tokens use the HKDF-derived key.
if ( str_starts_with( $encrypted_token, 'v2:' ) ) {
$decoded = base64_decode( substr( $encrypted_token, 3 ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
if ( false === $decoded || strlen( $decoded ) <= $iv_length ) {
return '';
}
$iv = substr( $decoded, 0, $iv_length );
$ciphertext = substr( $decoded, $iv_length );
$decrypted = openssl_decrypt( $ciphertext, 'aes-256-cbc', robotstxt_docmd_token_key(), 0, $iv );
return false !== $decrypted ? $decrypted : '';
}
// Legacy tokens (<= 1.1.1) used wp_salt('auth') directly as the key.
$decoded = base64_decode( $encrypted_token, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
if ( false === $decoded || strlen( $decoded ) <= $iv_length ) {
return '';
}
$iv = substr( $decoded, 0, $iv_length );
$ciphertext = substr( $decoded, $iv_length );
$decrypted = openssl_decrypt( $ciphertext, 'aes-256-cbc', wp_salt( 'auth' ), 0, $iv );
if ( false === $decrypted || '' === $decrypted ) {
return '';
}
// Lazy one-time migration to the v2 scheme.
$reencrypted = robotstxt_docmd_encrypt_token( $decrypted );
if ( '' !== $reencrypted ) {
robotstxt_docmd_persist_migrated_token( $reencrypted );
}
return $decrypted;
}
/**
* Persist a migrated (re-encrypted) token back into plugin settings.
*
* Runs at most once: after the first migration the stored value carries the
* `v2:` prefix and the legacy branch in robotstxt_docmd_decrypt_token() is no
* longer taken. Safe under concurrency because the re-encrypted value is
* deterministic for a given plaintext + key.
*
* @since 1.2.0
*
* @param string $reencrypted Re-encrypted token (v2 scheme).
* @return void
*/
function robotstxt_docmd_persist_migrated_token( string $reencrypted ): void {
$raw_settings = get_option( 'robotstxt_docmd_settings', array() );
if ( ! is_array( $raw_settings ) ) {
return;
}
$current = array_key_exists( 'github_token', $raw_settings ) && is_string( $raw_settings['github_token'] )
? $raw_settings['github_token']
: '';
// Only update while the stored token is still the legacy (unprefixed) one.
if ( '' === $current || str_starts_with( $current, 'v2:' ) ) {
return;
}
$raw_settings['github_token'] = $reencrypted;
update_option( 'robotstxt_docmd_settings', $raw_settings, false );
}
/**
* Generate title from file path
*
* Converts "api-documentation.md" to "API Documentation"
*
* @since 1.0.0
*
* @param string $file_path File path.
* @return string Generated title.
*/
function robotstxt_docmd_generate_title_from_path( string $file_path ): string {
// Get filename without extension.
$filename = basename( $file_path );
$title = preg_replace( '/\.(md|markdown)$/i', '', $filename );
$title = is_string( $title ) ? $title : $filename;
// Replace separators with spaces.
$title = str_replace( array( '-', '_' ), ' ', $title );
// Capitalize words.
$title = ucwords( $title );
// Handle common abbreviations.
$abbreviations = array(
'Api' => 'API',
'Faq' => 'FAQ',
'Url' => 'URL',
'Html' => 'HTML',
'Css' => 'CSS',
'Php' => 'PHP',
'Json' => 'JSON',
'Xml' => 'XML',
'Sql' => 'SQL',
);
foreach ( $abbreviations as $search => $replace ) {
$result = preg_replace( '/\b' . $search . '\b/', $replace, $title );
$title = is_string( $result ) ? $result : $title;
}
return $title;
}
/**
* Get status badge HTML
*
* @since 1.0.0
*
* @param string $status Status (never, pending, success, error).
* @return string Badge HTML.
*/
function robotstxt_docmd_get_status_badge( string $status ): string {
$badges = array(
'never' => array(
'class' => 'status-never',
'label' => __( 'Never Synced', 'robotstxt-documentation-markdown' ),
),
'pending' => array(
'class' => 'status-pending',
'label' => __( 'Syncing...', 'robotstxt-documentation-markdown' ),
),
'success' => array(
'class' => 'status-success',
'label' => __( 'Success', 'robotstxt-documentation-markdown' ),
),
'error' => array(
'class' => 'status-error',
'label' => __( 'Error', 'robotstxt-documentation-markdown' ),
),
);
$badge = $badges[ $status ] ?? $badges['never'];
return sprintf(
'<span class="status-badge %s">%s</span>',
esc_attr( $badge['class'] ),
esc_html( $badge['label'] )
);
}
/**
* Safely get a string value from a superglobal array
*
* Provides PHPStan level 9 compliant type-narrowing for superglobal access.
*
* @since 1.0.1
*
* @param array<string, mixed> $input The superglobal array ($_POST, $_GET, etc.).
* @param string $key The key to look for.
* @param string $fallback Default value if key not found or not a string.
* @return string
*/
function robotstxt_docmd_input_string( array $input, string $key, string $fallback = '' ): string {
if ( isset( $input[ $key ] ) && is_string( $input[ $key ] ) ) {
return $input[ $key ];
}
return $fallback;
}
/**
* Safely get an integer value from a superglobal array
*
* Provides PHPStan level 9 compliant type-narrowing for superglobal access.
*
* @since 1.0.1
*
* @param array<string, mixed> $input The superglobal array ($_POST, $_GET, etc.).
* @param string $key The key to look for.
* @param int $fallback Default value if key not found or not numeric.
* @return int
*/
function robotstxt_docmd_input_int( array $input, string $key, int $fallback = 0 ): int {
if ( isset( $input[ $key ] ) && is_numeric( $input[ $key ] ) ) {
return (int) $input[ $key ];
}
return $fallback;
}