diff --git a/changelog.txt b/changelog.txt index 3dd2d2e..5c888c7 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,65 @@ == Changelog == += 1.2.0 = + +_Release date: 2026-08-07_ + +**Highlights** + +* New: synced post title is taken from the first H1 in the Markdown (and stripped from the body) +* New: repo-relative links are rewritten to the matching WordPress permalink +* New: repository images are sideloaded into the Media Library and referenced by attachment URL +* All deferred findings from the 1.1.1 pre-deploy audit resolved +* GitHub token encryption hardened with HKDF-SHA256 key derivation (with transparent migration of existing tokens) +* CSRF nonce added to the Discover Files "Refresh" action +* Minimum WordPress raised to 6.8; verified compatible with WordPress 7.1; PHP minimum declarations made consistent at 8.0 across all files + +**Added** + +* **Title from H1:** the synced post title is now taken from the first `# H1` heading in the Markdown (inline formatting stripped to plain text), with the H1 removed from the body to avoid a duplicate heading. Falls back to the filename-derived title when no H1 is present +* **Internal link translation:** repo-relative Markdown links (e.g. `./api.md`, `../README.md`) are rewritten to the permalink of the matching mapped WordPress content. External, `mailto:`, and anchor links are left untouched; links with no matching mapping keep their original URL +* **Repository image sideloading:** images referenced in the Markdown are downloaded from the repository, added to the Media Library, and their references replaced with the attachment URL. Already-imported images are reused on subsequent syncs (tracked per post); removed images are kept in the Media Library (non-destructive) + +**Security** + +* Patched CVE-2026-71478 (and advisory GHSA-2q4p-g7hv-5rgv) in league/commonmark — an unsafe-link filter bypass that could defeat `allow_unsafe_links: false`. Updated league/commonmark 2.8.2 → 2.9.0 +* GitHub token encryption now derives its AES-256 key with HKDF-SHA256 from `wp_salt('auth')` instead of using the salt directly; existing tokens are migrated transparently to the new `v2:` format on first decrypt (covers the cron path too) +* Discover Files "Refresh from GitHub" action is now nonce-protected — previously a crafted link could force an unrequested GitHub API call (CSRF) +* GitHub API request paths are now `rawurlencode()`d (defensive hardening) +* Sideloaded images are validated by extension (jpg, jpeg, png, gif, webp) and size (< 10 MB) before storage; SVG is intentionally excluded + +**Fixed** + +* "Using cached data / fetching fresh data" indicator on Discover Files now reflects reality — it always showed "cached" because the cache was tested after being populated +* `target_post_type` is now validated against registered public post types on save, falling back to `page` +* Debug action dispatch refactored into a single `switch`, each case retaining its `check_admin_referer()` check, to reduce the chance of a missing nonce check + +**Changed** + +* Inline `onclick` confirm on Delete replaced with a `data-confirm` attribute + delegated handler (CSP-friendlier, less fragile) +* Add Mapping "existing content" dropdown now queries only public post types (excludes attachments and the internal mapping CPT) with `no_found_rows` for better performance on large sites + +**Compatibility** + +* WordPress: 6.8 - 7.1 +* PHP: 8.0 - 8.5 +* MariaDB: 11.4 or newer + +**Developer** + +* Added `bin/preflight.sh` — automated pre-deploy gate (PHPCS, PHPStan 9, PHPCompatibility, PHPUnit, `composer audit`, candidate-ZIP inspection); PASS/FAIL report per section, never invokes `deploy.sh` +* Added `.claude/settings.json` deny rules (`deploy.sh`, `git push/tag/merge`) enforcing the AGENTS.md operating boundaries mechanically +* `composer.json` `require-dev` completed and pinned: added `dealerdirect/phpcodesniffer-composer-installer` + `phpcsstandards/phpcsutils`; pinned `squizlabs/php_codesniffer` and `johnbillion/wp-compat` +* Patched two high-severity CVEs in dev tooling (not shipped — `deploy.sh` uses `--no-dev`): `squizlabs/php_codesniffer` 3.13.5 → 3.13.6 (CVE-2026-67434), `wp-coding-standards/wpcs` 3.3.0 → 3.4.1 (CVE-2026-45293). `composer audit` fully clean + +**Tests** + +* PHP Coding Standards: PHPCS with WordPress-Core, WordPress-Docs, WordPress-Extra — 0 errors +* PHPStan: level 9, 0 errors +* PHPCompatibility: PHP 8.0-8.5 validated +* PHPUnit: plugin header tests pass +* Manual testing: WordPress 7.1 + = 1.1.1 = _Release date: 2026-06-08_ diff --git a/readme.txt b/readme.txt index 2821062..914f7e5 100644 --- a/readme.txt +++ b/readme.txt @@ -1,10 +1,10 @@ === Documentation Markdown (by ROBOTSTXT) === Contributors: robotstxt Tags: github, documentation, markdown, sync, automation -Requires at least: 6.7 +Requires at least: 6.8 Tested up to: 7.1 Requires PHP: 8.0 -Stable tag: 1.1.1 +Stable tag: 1.2.0 License: GPLv3 or later License URI: https://www.gnu.org/licenses/gpl-3.0.html @@ -44,8 +44,8 @@ Synchronize Markdown documentation from GitHub repositories to WordPress pages a = Requirements = -* PHP 8.2 or higher -* WordPress 6.9 or higher +* PHP 8.0 or higher +* WordPress 6.8 or higher * GitHub Personal Access Token (free, for accessing repositories) * Composer (for production build with dependencies) @@ -63,7 +63,7 @@ Synchronize Markdown documentation from GitHub repositories to WordPress pages a * Clean, well-documented code * Follows WordPress Coding Standards (WPCS) -* Modern PHP 8.2+ features +* Modern PHP 8.0+ features * Extensive PHPDoc documentation * Procedural approach (KISS principles) * Extensible with WordPress hooks and filters @@ -178,13 +178,46 @@ Then go to Documentation → Settings, and you'll see a "Debug Tools" section at == Compatibility == -* WordPress: 6.7 - 7.1 +* WordPress: 6.8 - 7.1 * PHP: 8.0 - 8.5 == Changelog == For the complete changelog, see [changelog.txt](https://git.robotstxt.es/ROBOTSTXT/robotstxt-documentation-markdown/raw/branch/main/changelog.txt). += 1.2.0 - 2026-08-07 = + +**Added** + +* **Title from H1:** the synced post title is now taken from the first `# H1` in the Markdown (inline formatting stripped), with the H1 removed from the body to avoid a duplicate heading. Falls back to the filename-derived title when no H1 is present +* **Internal link translation:** repo-relative Markdown links (e.g. `./api.md`, `../README.md`) are rewritten to the permalink of the matching mapped WordPress content. External, `mailto:`, and anchor links are left untouched; links with no matching mapping keep their original URL +* **Repository image sideloading:** images referenced in the Markdown are downloaded from the repository, added to the Media Library, and their references replaced with the attachment URL. Already-imported images are reused on subsequent syncs (tracked per post); removed images are kept in the Media Library (non-destructive) + +**Security** + +* Patched CVE-2026-71478 (and advisory GHSA-2q4p-g7hv-5rgv) in league/commonmark — an unsafe-link filter bypass that could defeat `allow_unsafe_links: false`. Updated league/commonmark 2.8.2 → 2.9.0 +* GitHub token encryption now derives its AES-256 key with HKDF-SHA256 from `wp_salt('auth')` instead of using the salt directly; existing tokens are migrated transparently to the new `v2:` format on first decrypt (covers the cron path too) +* Discover Files "Refresh from GitHub" action is now nonce-protected — previously a crafted link could force an unrequested GitHub API call (CSRF) +* GitHub API request paths are now `rawurlencode()`d (defensive hardening) +* Sideloaded images are validated by extension (jpg, jpeg, png, gif, webp) and size (< 10 MB) before storage; SVG is intentionally excluded + +**Fixed** + +* "Using cached data / fetching fresh data" indicator on Discover Files now reflects reality — it always showed "cached" because the cache was tested after being populated +* `target_post_type` is now validated against registered public post types on save, falling back to `page` (prevents saving an unregistered or internal type) +* Debug action dispatch refactored into a single `switch`, each case retaining its `check_admin_referer()` check, to reduce the chance of a missing nonce check + +**Changed** + +* Inline `onclick` confirm on Delete replaced with a `data-confirm` attribute + delegated handler (CSP-friendlier, less fragile) +* Add Mapping "existing content" dropdown now queries only public post types (excludes attachments and the internal mapping CPT) with `no_found_rows` for better performance on large sites + +**Compatibility** + +* Minimum WordPress raised to 6.8 (latest stable + two previous majors) +* Verified compatible with WordPress 7.1 +* Declared PHP minimum made consistent across `readme.txt`, `composer.json`, `phpstan.neon`, and the PHPCompatibility scan range — real minimum remains 8.0 + = 1.1.1 - 2026-06-08 = **Security** @@ -274,7 +307,7 @@ For the complete changelog, see [changelog.txt](https://git.robotstxt.es/ROBOTST **Developer Features:** * Procedural PHP following KISS principles -* PHP 8.2+ modern features +* PHP 8.0+ modern features * Complete PHPDoc documentation * WordPress hooks and filters * Extensible architecture diff --git a/robotstxt-documentation-markdown-content.php b/robotstxt-documentation-markdown-content.php new file mode 100644 index 0000000..45d9f4c --- /dev/null +++ b/robotstxt-documentation-markdown-content.php @@ -0,0 +1,422 @@ + alt text. + '/\[([^\]]*)\]\([^)]*\)/', // Links -> label text. + '/`([^`]*)`/', // Inline code -> contents. + '/(\*\*|__|~~|\*|_)(.+?)\1/', // Bold/italic/strikethrough -> contents. + ); + $replacements = array( '$1', '$1', '$1', '$2' ); + + $result = preg_replace( $patterns, $replacements, $text ); + if ( ! is_string( $result ) ) { + $result = $text; + } + + return sanitize_text_field( trim( $result ) ); +} + +/** + * Extract the post title from the first H1 in the Markdown, and remove that + * H1 line from the content so it does not duplicate the theme-rendered title. + * + * Falls back to $fallback_title when no H1 is present or the extracted text + * is empty after sanitization. + * + * @since 1.2.0 + * + * @param string $content Raw Markdown. + * @param string $fallback_title Fallback title if no H1 is found. + * @return array{title: string, content: string} { + * Extracted title (plain text) and the Markdown with the first H1 removed. + * + * @type string $title Post title. + * @type string $content Markdown with the first H1 line removed. + * } + */ +function robotstxt_docmd_extract_h1_title( string $content, string $fallback_title ): array { + if ( preg_match( '/^#[ \t]+(.+)$/m', $content, $matches ) ) { + $title = robotstxt_docmd_plain_text_from_inline_markdown( trim( $matches[1] ) ); + if ( '' === $title ) { + $title = $fallback_title; + } + + $replaced = preg_replace( '/^#[ \t]+.*$/m', '', $content, 1 ); + if ( is_string( $replaced ) ) { + $content = $replaced; + } + } else { + $title = $fallback_title; + } + + return array( + 'title' => $title, + 'content' => $content, + ); +} + +/** + * Resolve a (possibly relative) Markdown link target to a canonical repository + * path, relative to the file currently being synced. + * + * Handles "./file.md", "../file.md", "sub/file.md", and absolute "/file.md" + * forms. Returns null for empty targets. + * + * @since 1.2.0 + * + * @param string $current_file_path Repository path of the file being synced. + * @param string $href Raw link target. + * @return string|null Canonical repository path, or null if it cannot be resolved. + */ +function robotstxt_docmd_resolve_repo_path( string $current_file_path, string $href ): ?string { + // Strip any fragment or query string before resolving. + $path = preg_replace( '/[?#].*$/', '', $href ); + if ( ! is_string( $path ) || '' === $path ) { + return null; + } + + if ( 0 === strpos( $path, '/' ) ) { + // Absolute repository path. + $base_segments = array(); + $rel_segments = explode( '/', ltrim( $path, '/' ) ); + } else { + // Relative to the current file's directory. + $dir = dirname( $current_file_path ); + $base_segments = '.' === $dir ? array() : explode( '/', $dir ); + $rel_segments = explode( '/', $path ); + } + + $result = $base_segments; + foreach ( $rel_segments as $segment ) { + if ( '' === $segment || '.' === $segment ) { + continue; + } + if ( '..' === $segment ) { + array_pop( $result ); + continue; + } + $result[] = $segment; + } + + $normalized = implode( '/', $result ); + if ( '' === $normalized ) { + return null; + } + + return $normalized; +} + +/** + * Build a lookup of canonical repository file paths to WordPress permalinks, + * scoped to the given repository and branch. Only mappings with an existing + * target post are included. + * + * @since 1.2.0 + * + * @param string $owner Repository owner. + * @param string $repo Repository name. + * @param string $branch Branch name. + * @return array Map of repository path => permalink. + */ +function robotstxt_docmd_build_link_map( string $owner, string $repo, string $branch ): array { + $mappings = robotstxt_docmd_get_all_mappings(); + $map = array(); + + foreach ( $mappings as $mapping ) { + if ( $mapping['repo_owner'] !== $owner || $mapping['repo_name'] !== $repo || $mapping['branch'] !== $branch ) { + continue; + } + if ( empty( $mapping['target_post_id'] ) ) { + continue; + } + + $permalink = get_permalink( (int) $mapping['target_post_id'] ); + if ( is_string( $permalink ) && '' !== $permalink ) { + $map[ $mapping['file_path'] ] = $permalink; + } + } + + return $map; +} + +/** + * Get the imported-image asset map for a synced post. + * + * @since 1.2.0 + * + * @param int $post_id Target post ID. + * @return array Map of repository image path => attachment ID. + */ +function robotstxt_docmd_get_asset_map( int $post_id ): array { + $raw = get_post_meta( $post_id, '_robotstxt_docmd_assets', true ); + if ( ! is_array( $raw ) ) { + return array(); + } + + $map = array(); + foreach ( $raw as $path => $attachment_id ) { + if ( is_string( $path ) && ( is_int( $attachment_id ) || ( is_string( $attachment_id ) && ctype_digit( $attachment_id ) ) ) ) { + $map[ $path ] = (int) $attachment_id; + } + } + + return $map; +} + +/** + * Persist the imported-image asset map for a synced post. + * + * @since 1.2.0 + * + * @param int $post_id Target post ID. + * @param array $map Map of repository image path => attachment ID. + * @phpstan-param array $map + * @return void + */ +function robotstxt_docmd_save_asset_map( int $post_id, array $map ): void { + update_post_meta( $post_id, '_robotstxt_docmd_assets', $map ); +} + +/** + * Return the list of image extensions allowed for sideloading. + * + * SVG is intentionally excluded to avoid stored XSS via embedded scripts. + * + * @since 1.2.0 + * + * @return list + */ +function robotstxt_docmd_allowed_image_extensions(): array { + return array( 'jpg', 'jpeg', 'png', 'gif', 'webp' ); +} + +/** + * Download an image from the repository and import it into the Media Library. + * + * @since 1.2.0 + * + * @param string $repo_path Canonical repository path of the image. + * @param int $target_post_id Post to attach the image to. + * @param string $owner Repository owner. + * @param string $repo Repository name. + * @param string $branch Branch name. + * @param string $token Encrypted GitHub token. + * @return int|WP_Error Attachment ID on success, or WP_Error. + */ +function robotstxt_docmd_sideload_repo_image( string $repo_path, int $target_post_id, string $owner, string $repo, string $branch, string $token ): int|WP_Error { + $ext = strtolower( (string) pathinfo( $repo_path, PATHINFO_EXTENSION ) ); + $allowed = robotstxt_docmd_allowed_image_extensions(); + if ( ! in_array( $ext, $allowed, true ) ) { + return new WP_Error( + 'unsupported_image_type', + sprintf( + /* translators: %s: file extension */ + __( 'Unsupported image type .%s. Allowed: jpg, jpeg, png, gif, webp.', 'robotstxt-documentation-markdown' ), + $ext + ) + ); + } + + // Fetch image bytes from GitHub. + $bytes = robotstxt_docmd_get_file_content( $owner, $repo, $repo_path, $branch, $token ); + if ( is_wp_error( $bytes ) ) { + return $bytes; + } + + // Size guard (10 MB). + if ( strlen( $bytes ) > 10 * 1024 * 1024 ) { + return new WP_Error( 'image_too_large', __( 'Repository image exceeds the 10 MB import limit.', 'robotstxt-documentation-markdown' ) ); + } + + if ( ! function_exists( 'media_handle_sideload' ) ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/media.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + } + + $filename = sanitize_file_name( basename( $repo_path ) ); + $tmp_name = wp_tempnam( $filename ); + if ( '' === $tmp_name ) { + return new WP_Error( 'temp_failed', __( 'Could not create a temporary file for image import.', 'robotstxt-documentation-markdown' ) ); + } + + global $wp_filesystem; + if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + WP_Filesystem(); + } + + if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) { + wp_delete_file( $tmp_name ); + return new WP_Error( 'fs_failed', __( 'Could not initialize the WordPress filesystem.', 'robotstxt-documentation-markdown' ) ); + } + + if ( ! $wp_filesystem->put_contents( $tmp_name, $bytes, FS_CHMOD_FILE ) ) { + wp_delete_file( $tmp_name ); + return new WP_Error( 'write_failed', __( 'Could not write image to the temporary file.', 'robotstxt-documentation-markdown' ) ); + } + + $attach_id = media_handle_sideload( + array( + 'name' => $filename, + 'tmp_name' => $tmp_name, + ), + $target_post_id + ); + + if ( is_wp_error( $attach_id ) ) { + wp_delete_file( $tmp_name ); + return $attach_id; + } + + return (int) $attach_id; +} + +/** + * Post-process synced HTML: rewrite internal links to mapped permalinks and + * sideload repository images into the Media Library. + * + * Internal-link and image references are parsed with DOMDocument (no regex on + * HTML). Images already present in $asset_map are reused. Image import errors + * are non-fatal: the original src is kept and the sync continues. + * + * @since 1.2.0 + * + * @param string $html Converted HTML content. + * @param array $mapping Mapping being synced. + * @param int $target_post_id Target post ID (for image attachment). + * @param string $token Encrypted GitHub token (for image fetch). + * @param array $asset_map Existing imported-image map (path => attach ID). + * @phpstan-param MappingData $mapping + * @phpstan-param array $asset_map + * @return array{html: string, assets: array} Processed HTML and the updated asset map. + */ +function robotstxt_docmd_process_synced_html( string $html, array $mapping, int $target_post_id, string $token, array $asset_map ): array { + $dom = new DOMDocument(); + libxml_use_internal_errors( true ); + $dom->loadHTML( + '' . $html . '' + ); + libxml_clear_errors(); + + $link_map = robotstxt_docmd_build_link_map( $mapping['repo_owner'], $mapping['repo_name'], $mapping['branch'] ); + + // Rewrite internal anchor hrefs. + foreach ( $dom->getElementsByTagName( 'a' ) as $link ) { + $href = $link->getAttribute( 'href' ); + if ( '' === $href ) { + continue; + } + + // Skip absolute, protocol-relative, mailto/tel, and anchor-only links. + if ( preg_match( '~^(https?:|mailto:|tel:|//|#)~', $href ) ) { + continue; + } + + $resolved = robotstxt_docmd_resolve_repo_path( $mapping['file_path'], $href ); + if ( null === $resolved ) { + continue; + } + + if ( isset( $link_map[ $resolved ] ) ) { + $link->setAttribute( 'href', $link_map[ $resolved ] ); + } + } + + // Sideload or reuse repository images. + foreach ( $dom->getElementsByTagName( 'img' ) as $image ) { + $src = $image->getAttribute( 'src' ); + if ( '' === $src ) { + continue; + } + + // Skip absolute, protocol-relative, and data URIs. + if ( preg_match( '~^(https?:|//|data:)~', $src ) ) { + continue; + } + + $resolved = robotstxt_docmd_resolve_repo_path( $mapping['file_path'], $src ); + if ( null === $resolved ) { + continue; + } + + // Reuse an already-imported attachment. + if ( isset( $asset_map[ $resolved ] ) ) { + $url = wp_get_attachment_url( (int) $asset_map[ $resolved ] ); + if ( is_string( $url ) && '' !== $url ) { + $image->setAttribute( 'src', $url ); + } + continue; + } + + // Import the image from the repository. + $attach_id = robotstxt_docmd_sideload_repo_image( + $resolved, + $target_post_id, + $mapping['repo_owner'], + $mapping['repo_name'], + $mapping['branch'], + $token + ); + + if ( is_wp_error( $attach_id ) ) { + // Keep the original src; do not block the sync. + continue; + } + + $asset_map[ $resolved ] = $attach_id; + $url = wp_get_attachment_url( $attach_id ); + if ( is_string( $url ) && '' !== $url ) { + $image->setAttribute( 'src', $url ); + } + } + + // Extract the processed body HTML. + $body = $dom->getElementsByTagName( 'body' )->item( 0 ); + if ( ! $body instanceof DOMElement ) { + return array( + 'html' => $html, + 'assets' => $asset_map, + ); + } + + $output = ''; + foreach ( $body->childNodes as $child ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOMDocument API property. + $saved = $dom->saveHTML( $child ); + if ( is_string( $saved ) ) { + $output .= $saved; + } + } + + if ( '' === $output ) { + $output = $html; + } + + return array( + 'html' => $output, + 'assets' => $asset_map, + ); +} diff --git a/robotstxt-documentation-markdown-functions.php b/robotstxt-documentation-markdown-functions.php index ad32b4d..1fd5c21 100644 --- a/robotstxt-documentation-markdown-functions.php +++ b/robotstxt-documentation-markdown-functions.php @@ -10,21 +10,39 @@ 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, or empty string on failure. + * @return string Encrypted token (v2-prefixed), or empty string on failure. */ function robotstxt_docmd_encrypt_token( string $token ): string { - if ( empty( $token ) ) { + if ( '' === $token ) { return ''; } - // Use WordPress salts for encryption key. - $key = wp_salt( 'auth' ); + $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 ); @@ -33,34 +51,97 @@ function robotstxt_docmd_encrypt_token( string $token ): string { return ''; } - return base64_encode( $iv . $encrypted ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + 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. + * @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 ( empty( $encrypted_token ) ) { + if ( '' === $encrypted_token ) { return ''; } - $key = wp_salt( 'auth' ); $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 ); - $encrypted = substr( $decoded, $iv_length ); - $decrypted = openssl_decrypt( $encrypted, 'aes-256-cbc', $key, 0, $iv ); - return false !== $decrypted ? $decrypted : ''; + $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 ); } /** diff --git a/robotstxt-documentation-markdown-github.php b/robotstxt-documentation-markdown-github.php index 95fb9c4..7ecbd53 100644 --- a/robotstxt-documentation-markdown-github.php +++ b/robotstxt-documentation-markdown-github.php @@ -135,10 +135,10 @@ function robotstxt_docmd_get_repository_contents_recursive( string $owner, strin $url = sprintf( 'https://api.github.com/repos/%s/%s/contents/%s?ref=%s', - $owner, - $repo, - $path, - $branch + rawurlencode( $owner ), + rawurlencode( $repo ), + rawurlencode( $path ), + rawurlencode( $branch ) ); $response = wp_remote_get( @@ -239,10 +239,10 @@ function robotstxt_docmd_get_file_content( string $owner, string $repo, string $ $url = sprintf( 'https://api.github.com/repos/%s/%s/contents/%s?ref=%s', - $owner, - $repo, - $file_path, - $branch + rawurlencode( $owner ), + rawurlencode( $repo ), + rawurlencode( $file_path ), + rawurlencode( $branch ) ); $response = wp_remote_get( diff --git a/robotstxt-documentation-markdown-map.php b/robotstxt-documentation-markdown-map.php index 5ae5ebd..e9514c9 100644 --- a/robotstxt-documentation-markdown-map.php +++ b/robotstxt-documentation-markdown-map.php @@ -292,6 +292,11 @@ function robotstxt_docmd_sync_mapping( int $mapping_id ): bool|WP_Error { return $content; } + // Derive the post title from the first H1 and remove that H1 from the body. + $extracted = robotstxt_docmd_extract_h1_title( $content, $mapping['title'] ); + $content = $extracted['content']; + $post_title = $extracted['title']; + // Convert Markdown to HTML using CommonMark. // Strip raw HTML from Markdown to prevent stored XSS via compromised upstream repos. $converter = new \League\CommonMark\CommonMarkConverter( @@ -307,7 +312,7 @@ function robotstxt_docmd_sync_mapping( int $mapping_id ): bool|WP_Error { // Create new post. $post_id = wp_insert_post( array( - 'post_title' => $mapping['title'], + 'post_title' => $post_title, 'post_content' => $html_content, 'post_type' => $mapping['target_post_type'], 'post_status' => 'publish', @@ -325,20 +330,35 @@ function robotstxt_docmd_sync_mapping( int $mapping_id ): bool|WP_Error { // Save the post ID for future syncs. update_post_meta( $mapping_id, '_robotstxt_docmd_target_post_id', $post_id ); + $target_post_id = (int) $post_id; } else { - // Update existing post. - $update_result = wp_update_post( - array( - 'ID' => $mapping['target_post_id'], - 'post_content' => $html_content, - ), - true - ); + $target_post_id = (int) $mapping['target_post_id']; + } - if ( is_wp_error( $update_result ) ) { - update_post_meta( $mapping_id, '_robotstxt_docmd_sync_status', 'error' ); - return $update_result; - } + // Post-process: translate internal links and sideload repository images. + $asset_map = robotstxt_docmd_get_asset_map( $target_post_id ); + $processed = robotstxt_docmd_process_synced_html( $html_content, $mapping, $target_post_id, $token, $asset_map ); + + if ( '' !== $processed['html'] ) { + $html_content = $processed['html']; + } + robotstxt_docmd_save_asset_map( $target_post_id, $processed['assets'] ); + + // Persist the rewritten content and the H1-derived title. + // NOTE: post_name (slug) is intentionally NEVER passed here, so the slug + // remains stable across syncs even though the title follows the H1. + $update_result = wp_update_post( + array( + 'ID' => $target_post_id, + 'post_title' => $post_title, + 'post_content' => $html_content, + ), + true + ); + + if ( is_wp_error( $update_result ) ) { + update_post_meta( $mapping_id, '_robotstxt_docmd_sync_status', 'error' ); + return $update_result; } // Update sync status. diff --git a/robotstxt-documentation-markdown.php b/robotstxt-documentation-markdown.php index e5eaf08..fb22840 100644 --- a/robotstxt-documentation-markdown.php +++ b/robotstxt-documentation-markdown.php @@ -3,8 +3,8 @@ * Plugin Name: Documentation Markdown (by ROBOTSTXT) * Plugin URI: https://git.robotstxt.es/ROBOTSTXT/robotstxt-documentation-markdown * Description: Synchronizes Markdown documentation from GitHub repositories to WordPress pages and posts automatically. - * Version: 1.1.1 - * Requires at least: 6.7 + * Version: 1.2.0 + * Requires at least: 6.8 * Requires PHP: 8.0 * Security: robotstxt@robotstxt.es * Author: ROBOTSTXT @@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) { } // Define plugin constants. -define( 'ROBOTSTXT_DOCMD_VERSION', '1.1.1' ); +define( 'ROBOTSTXT_DOCMD_VERSION', '1.2.0' ); define( 'ROBOTSTXT_DOCMD_PLUGIN_FILE', __FILE__ ); define( 'ROBOTSTXT_DOCMD_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'ROBOTSTXT_DOCMD_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); @@ -41,6 +41,7 @@ if ( file_exists( ROBOTSTXT_DOCMD_PLUGIN_DIR . 'vendor/autoload.php' ) ) { require_once ROBOTSTXT_DOCMD_PLUGIN_DIR . 'robotstxt-documentation-markdown-functions.php'; require_once ROBOTSTXT_DOCMD_PLUGIN_DIR . 'robotstxt-documentation-markdown-github.php'; require_once ROBOTSTXT_DOCMD_PLUGIN_DIR . 'robotstxt-documentation-markdown-map.php'; +require_once ROBOTSTXT_DOCMD_PLUGIN_DIR . 'robotstxt-documentation-markdown-content.php'; require_once ROBOTSTXT_DOCMD_PLUGIN_DIR . 'robotstxt-documentation-markdown-cron.php'; // Activation/Deactivation hooks. @@ -254,35 +255,34 @@ function robotstxt_docmd_handle_admin_actions() { if ( isset( $_GET['page'] ) && 'robotstxt-docmd-settings' === $_GET['page'] && isset( $_GET['debug_action'] ) ) { $debug_action = sanitize_key( wp_unslash( robotstxt_docmd_input_string( $_GET, 'debug_action' ) ) ); - if ( 'test_github' === $debug_action ) { - check_admin_referer( 'robotstxt_docmd_debug_test_github' ); - robotstxt_docmd_debug_test_github(); - return; - } + switch ( $debug_action ) { + case 'test_github': + check_admin_referer( 'robotstxt_docmd_debug_test_github' ); + robotstxt_docmd_debug_test_github(); + return; - if ( 'test_token' === $debug_action ) { - check_admin_referer( 'robotstxt_docmd_debug_test_token' ); - robotstxt_docmd_debug_test_token(); - return; - } + case 'test_token': + check_admin_referer( 'robotstxt_docmd_debug_test_token' ); + robotstxt_docmd_debug_test_token(); + return; - if ( 'run_cron' === $debug_action && isset( $_GET['mapping_id'] ) ) { - $mapping_id = robotstxt_docmd_input_int( $_GET, 'mapping_id' ); - check_admin_referer( 'robotstxt_docmd_debug_run_cron_' . $mapping_id ); - robotstxt_docmd_debug_run_cron( $mapping_id ); - return; - } + case 'run_cron': + if ( isset( $_GET['mapping_id'] ) ) { + $mapping_id = robotstxt_docmd_input_int( $_GET, 'mapping_id' ); + check_admin_referer( 'robotstxt_docmd_debug_run_cron_' . $mapping_id ); + robotstxt_docmd_debug_run_cron( $mapping_id ); + } + return; - if ( 'clear_cache' === $debug_action ) { - check_admin_referer( 'robotstxt_docmd_debug_clear_cache' ); - robotstxt_docmd_debug_clear_cache(); - return; - } + case 'clear_cache': + check_admin_referer( 'robotstxt_docmd_debug_clear_cache' ); + robotstxt_docmd_debug_clear_cache(); + return; - if ( 'fix_crons' === $debug_action ) { - check_admin_referer( 'robotstxt_docmd_debug_fix_crons' ); - robotstxt_docmd_debug_fix_crons(); - return; + case 'fix_crons': + check_admin_referer( 'robotstxt_docmd_debug_fix_crons' ); + robotstxt_docmd_debug_fix_crons(); + return; } } @@ -374,11 +374,23 @@ function robotstxt_docmd_render_mappings_page() { - - - + + + - +
+ + # + + @@ -392,9 +404,9 @@ function robotstxt_docmd_render_mappings_page() { - - - + + + @@ -402,6 +414,19 @@ function robotstxt_docmd_render_mappings_page() { + +