'bash', 'bash' => 'bash', 'zsh' => 'bash', 'c' => 'c', 'h' => 'c', 'cpp' => 'cpp', 'cc' => 'cpp', 'cs' => 'csharp', 'css' => 'css', 'dockerfile' => 'dockerfile', 'go' => 'go', 'graphql' => 'graphql', 'gql' => 'graphql', 'tf' => 'hcl', 'hcl' => 'hcl', 'html' => 'xml', 'htm' => 'xml', 'xml' => 'xml', 'java' => 'java', 'js' => 'javascript', 'mjs' => 'javascript', 'cjs' => 'javascript', 'jsx' => 'javascript', 'json' => 'json', 'kt' => 'kotlin', 'lua' => 'lua', 'mk' => 'makefile', 'makefile' => 'makefile', 'md' => 'markdown', 'pl' => 'perl', 'php' => 'php', 'py' => 'python', 'r' => 'r', 'rb' => 'ruby', 'rs' => 'rust', 'scss' => 'scss', 'sql' => 'sql', 'swift' => 'swift', 'toml' => 'toml', 'ts' => 'typescript', 'tsx' => 'typescript', 'yml' => 'yaml', 'yaml' => 'yaml', ); private const TABLE_VERSION = '1'; private const TABLE_VERSION_OPTION = 'csdt_github_sync_db_version'; public const CRON_HOOK = 'csdt_github_sync_daily'; private const CRON_BATCH = 100; // max rows synced per nightly tick // Re-entry guard so the cron-triggered wp_update_post doesn't recurse back through save_post. private static bool $is_cron_syncing = false; /** * Returns the fully-qualified index table name for the current site prefix. * * @since 1.9.0 * @return string Table name (e.g. wp_csdt_github_blocks). */ public static function table_name(): string { global $wpdb; return $wpdb->prefix . 'csdt_github_blocks'; } /** * Installs the linked-blocks index table on first activation and any later * schema bump (gated by an option-stored version). Safe to call on every * `init`, returns immediately when the stored version is current. * * @since 1.9.0 * @return void */ public static function maybe_install_table(): void { if ( get_option( self::TABLE_VERSION_OPTION ) === self::TABLE_VERSION ) { return; } global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table_name(); $charset = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL, block_uid VARCHAR(36) NOT NULL, github_url VARCHAR(2048) NOT NULL, github_snippet VARCHAR(128) NOT NULL DEFAULT '', last_sync INT UNSIGNED NOT NULL DEFAULT 0, last_sha CHAR(64) NOT NULL DEFAULT '', last_status VARCHAR(32) NOT NULL DEFAULT 'pending', last_error VARCHAR(255) NOT NULL DEFAULT '', created_at INT UNSIGNED NOT NULL DEFAULT 0, updated_at INT UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY post_block (post_id, block_uid), KEY last_sync (last_sync) ) {$charset};"; dbDelta( $sql ); update_option( self::TABLE_VERSION_OPTION, self::TABLE_VERSION, false ); } /** * Walks parsed blocks recursively and collects every cloudscale/code-block * that has BOTH a non-empty githubUrl and a githubBlockId set. * * Blocks where the URL has been cleared (unlinked) are deliberately * excluded, their index rows will be removed by the reconciler. */ private static function collect_linked_blocks( array $blocks ): array { $found = array(); foreach ( $blocks as $b ) { if ( ! is_array( $b ) ) { continue; } if ( ( $b['blockName'] ?? '' ) === 'cloudscale/code-block' ) { $attrs = $b['attrs'] ?? array(); $url = isset( $attrs['githubUrl'] ) ? trim( (string) $attrs['githubUrl'] ) : ''; $uid = isset( $attrs['githubBlockId'] ) ? (string) $attrs['githubBlockId'] : ''; if ( $url !== '' && $uid !== '' ) { $found[] = array( 'uid' => $uid, 'url' => $url, 'snippet' => isset( $attrs['githubSnippet'] ) ? (string) $attrs['githubSnippet'] : '', 'sha' => isset( $attrs['githubLastSha'] ) ? (string) $attrs['githubLastSha'] : '', ); } } if ( ! empty( $b['innerBlocks'] ) && is_array( $b['innerBlocks'] ) ) { $found = array_merge( $found, self::collect_linked_blocks( $b['innerBlocks'] ) ); } } return $found; } /** * Reconciles the index table against the current post_content. Three cases handled: * - new linked block → insert row * - existing block with changed url/snippet → update row * - block was deleted OR had its URL cleared → row deleted */ /** * `save_post` hook callback. Re-runs the indexer for the saved post so that * adding, editing, removing, or unlinking a code block is reflected in the * index immediately. * * @since 1.9.0 * @param int $post_id Post being saved. * @param \WP_Post $post Full post object. * @param bool $update True when updating an existing post. * @return void */ public static function on_save_post( int $post_id, \WP_Post $post, bool $update ): void { if ( self::$is_cron_syncing ) { return; } if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } // Skip auto-drafts, trashed posts, and other non-content states. if ( ! in_array( $post->post_status, array( 'publish', 'draft', 'pending', 'future', 'private' ), true ) ) { return; } try { self::index_post( $post_id, $post->post_content ); } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Telegram' ) && CSDT_Telegram::is_configured() ) { CSDT_Alert_Events::send( CSDT_Alert_Events::GENERAL, sprintf( "GitHub Sync index failed on post save\nPost: #%d\nError: %s", $post_id, $e->getMessage() ), 'CloudScale Cyber Devtools', 'error' ); } } } /** * Reconciles the index for one post: upserts rows for currently-linked * blocks and deletes rows whose UID is no longer present (covers both * block-deleted and URL-cleared cases). * * @since 1.9.0 * @param int $post_id Post whose blocks to index. * @param string $content Raw post_content to parse for linked blocks. * @return void */ private static function index_post( int $post_id, string $content ): void { global $wpdb; $table = self::table_name(); $now = time(); $found = self::collect_linked_blocks( parse_blocks( $content ) ); $current_uids = array(); foreach ( $found as $blk ) { $current_uids[] = $blk['uid']; $existing = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable $wpdb->prepare( 'SELECT id, github_url, github_snippet FROM %i WHERE post_id = %d AND block_uid = %s', $table, $post_id, $blk['uid'] ) ); if ( $existing ) { if ( $existing->github_url !== $blk['url'] || $existing->github_snippet !== $blk['snippet'] ) { $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable $table, array( 'github_url' => $blk['url'], 'github_snippet' => $blk['snippet'], 'updated_at' => $now, ), array( 'id' => $existing->id ), array( '%s', '%s', '%d' ), array( '%d' ) ); } } else { $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable $table, array( 'post_id' => $post_id, 'block_uid' => $blk['uid'], 'github_url' => $blk['url'], 'github_snippet' => $blk['snippet'], 'last_sync' => 0, 'last_sha' => $blk['sha'], 'last_status' => $blk['sha'] !== '' ? 'ok' : 'pending', 'last_error' => '', 'created_at' => $now, 'updated_at' => $now, ), array( '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%d', '%d' ) ); } } // Remove rows for blocks that no longer exist in the post (block deleted) // or whose link was cleared (URL removed). Both cases share this cleanup. if ( empty( $current_uids ) ) { $wpdb->delete( $table, array( 'post_id' => $post_id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable } else { // Block ids are UUIDs from newBlockUid() in the editor, so they carry no comma and the // keep-list travels as ONE %s value for FIND_IN_SET(); the statement is a literal and // no placeholder list is assembled into it. A hand-edited id containing a comma is // left out of the keep-list, so its row is not kept. $keep = array_filter( $current_uids, static fn( $u ) => false === strpos( (string) $u, ',' ) ); $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable $wpdb->prepare( 'DELETE FROM %i WHERE post_id = %d AND NOT FIND_IN_SET( block_uid, %s )', $table, $post_id, implode( ',', $keep ) ) ); } } /** * Drops every index row associated with a post. Wired to both * `before_delete_post` and `wp_trash_post` so trashed posts stop syncing. * * @since 1.9.0 * @param int $post_id Post being trashed or deleted. * @return void */ public static function on_post_deleted( int $post_id ): void { global $wpdb; $wpdb->delete( self::table_name(), array( 'post_id' => $post_id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable } // ── Cron sweep ───────────────────────────────────────────────────────── /** * Daily cron entry point. Pulls the oldest-synced batch of linked blocks * (capped at CRON_BATCH) and resyncs each one. Safe to call on demand. * * @since 1.9.0 * @return void */ /** * Set by fetch_url() when the provider refuses for rate, read by cron_sweep(). * * @var bool */ private static $rate_limited = false; public static function cron_sweep(): void { global $wpdb; $table = self::table_name(); $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable $wpdb->prepare( 'SELECT * FROM %i ORDER BY last_sync ASC LIMIT %d', $table, self::CRON_BATCH ) ); if ( ! is_array( $rows ) ) { return; } self::$rate_limited = false; foreach ( $rows as $row ) { self::sync_one_row( $row ); if ( self::$rate_limited ) { break; // the remaining rows keep their stored content and their turn in the queue. } } } /** * Syncs a single index row: parses the stored URL, fetches the latest body, * skips the rewrite when the SHA is unchanged, otherwise extracts the * snippet, writes the post, and updates the index status. * * @since 1.9.0 * @param object $row Database row from the index table. * @return void */ private static function sync_one_row( object $row ): void { global $wpdb; $table = self::table_name(); $now = time(); $provider = CSDT_Git_Providers::detect( (string) $row->github_url ); $parts = $provider ? $provider->parse( (string) $row->github_url ) : null; if ( ! $provider || ! $parts ) { self::record_row_error( (int) $row->id, 'invalid url', $now ); return; } $fetch = self::fetch_url( $provider->raw_url( $parts ) ); if ( is_wp_error( $fetch ) && 'rate_limited' === $fetch->get_error_code() ) { return; // not this row's fault: leave its status and content exactly as they were. } if ( is_wp_error( $fetch ) ) { self::record_row_error( (int) $row->id, CSDT_Error_Text::in_seconds( $fetch->get_error_message() ), $now ); return; } $extracted = self::extract_snippet( $fetch['body'], (string) $row->github_snippet ); if ( is_wp_error( $extracted ) ) { self::record_row_error( (int) $row->id, CSDT_Error_Text::in_seconds( $extracted->get_error_message() ), $now ); return; } // SHA matches what we last synced → mark fresh and skip the rewrite, // but ONLY if the stored block still holds the content we wrote. The // block's content can be damaged after a successful sync (a corrupting // paste, an over-aggressive sanitiser eating <...> spans) without the // index noticing; a SHA-only check would then skip the repair forever. if ( $row->last_sha !== '' && $fetch['sha'] === $row->last_sha && self::block_content_matches( (int) $row->post_id, (string) $row->block_uid, $extracted['content'] ) ) { $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable $table, array( 'last_sync' => $now, 'last_status' => 'ok', 'last_error' => '', 'updated_at' => $now, ), array( 'id' => $row->id ), array( '%d', '%s', '%s', '%d' ), array( '%d' ) ); return; } // Integrity check: confirm the row still describes the same block in // the same post (same UID + same githubUrl) before we rewrite content. // If the row diverged from the post (e.g. via a future SQL-write vuln, // or stale post state after an external edit), drop the row and let // save_post recreate it from authoritative content next time. $post = get_post( (int) $row->post_id ); if ( ! $post || ! self::row_matches_post( $post->post_content, (string) $row->block_uid, (string) $row->github_url ) ) { $wpdb->delete( $table, array( 'id' => $row->id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable return; } $ok = self::update_block_content_in_post( (int) $row->post_id, (string) $row->block_uid, $extracted['content'], $now, $fetch['sha'] ); if ( ! $ok ) { // The post or block UID disappeared between index time and sweep time. // Drop the orphan row so it stops getting retried. $wpdb->delete( $table, array( 'id' => $row->id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable return; } $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable $table, array( 'last_sync' => $now, 'last_sha' => $fetch['sha'], 'last_status' => 'ok', 'last_error' => '', 'updated_at' => $now, ), array( 'id' => $row->id ), array( '%d', '%s', '%s', '%s', '%d' ), array( '%d' ) ); } /** * Stamps an error onto an index row so the failure surfaces in the admin * and the row still rotates to the back of the next sync's priority queue. * * @since 1.9.0 * @param int $row_id Index row id. * @param string $msg Error message (truncated to 250 chars). * @param int $now Unix timestamp to record as the sync attempt time. * @return void */ private static function record_row_error( int $row_id, string $msg, int $now ): void { global $wpdb; $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- GitHub Sync custom table; WP_Query not applicable self::table_name(), array( 'last_sync' => $now, 'last_status' => 'error', 'last_error' => substr( $msg, 0, 250 ), 'updated_at' => $now, ), array( 'id' => $row_id ), array( '%d', '%s', '%s', '%d' ), array( '%d' ) ); } /** * Decodes legacy HTML-entity-encoded attribute values (e.g. `cat > f`) * to the canonical raw form the editor writes (`cat > f`). Mirrors * decodeHtmlEntities() in blocks/code/editor.js, including its "only when * entities are actually present" guard, so PHP and JS agree on the value. */ private static function decode_legacy_entities( string $s ): string { if ( $s === '' || ! preg_match( '/&[a-zA-Z#0-9]+;/', $s ) ) { return $s; } return html_entity_decode( $s, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); } /** * Returns true when the block identified by $uid inside $post_id currently * holds exactly the content a sync would write (prefix + synced body + * suffix, with prefix/suffix normalised the same way walk_and_update() * composes them). Any drift, external corruption, a manual edit, or * legacy entity-encoded content, returns false so the sweep rewrites * (and thereby repairs/normalises) the block. */ private static function block_content_matches( int $post_id, string $uid, string $new_content ): bool { $post = get_post( $post_id ); if ( ! $post ) { return false; } $attrs = null; $walk = static function ( array $blocks ) use ( &$walk, $uid, &$attrs ): void { foreach ( $blocks as $b ) { if ( null !== $attrs ) { return; } if ( ! is_array( $b ) ) { continue; } if ( ( $b['blockName'] ?? '' ) === 'cloudscale/code-block' && ( ( $b['attrs']['githubBlockId'] ?? '' ) === $uid ) ) { $attrs = $b['attrs'] ?? array(); return; } if ( ! empty( $b['innerBlocks'] ) && is_array( $b['innerBlocks'] ) ) { $walk( $b['innerBlocks'] ); } } }; $walk( parse_blocks( $post->post_content ) ); if ( null === $attrs ) { return false; } $prefix = self::decode_legacy_entities( (string) ( $attrs['githubPrefix'] ?? '' ) ); $suffix = self::decode_legacy_entities( (string) ( $attrs['githubSuffix'] ?? '' ) ); return (string) ( $attrs['content'] ?? '' ) === $prefix . $new_content . $suffix; } /** * Locates a cloudscale/code-block by githubBlockId inside the post's content, * updates its content/lastSync/lastSha attrs, and writes the post back without * triggering a revision or re-entering save_post. */ private static function update_block_content_in_post( int $post_id, string $uid, string $new_content, int $synced_at, string $sha ): bool { $post = get_post( $post_id ); if ( ! $post ) { return false; } $blocks = parse_blocks( $post->post_content ); $updated = false; self::walk_and_update( $blocks, $uid, $new_content, $synced_at, $sha, $updated ); if ( ! $updated ) { return false; } $serialized = serialize_blocks( $blocks ); self::$is_cron_syncing = true; $no_revisions = static function () { return 0; }; add_filter( 'wp_revisions_to_keep', $no_revisions, 99 ); // wp_update_post calls wp_unslash internally and expects slashed data. // Without wp_slash, JSON-encoded block attrs (e.g. > for `>`) // lose their backslashes and become literal `u003e` in the DB. wp_update_post( array( 'ID' => $post_id, 'post_content' => wp_slash( $serialized ), ) ); remove_filter( 'wp_revisions_to_keep', $no_revisions, 99 ); self::$is_cron_syncing = false; return true; } /** * Verifies that a block with the given UID exists in the post's content * AND its githubUrl attribute equals the supplied URL. Returns false if * the block is missing, or its URL has diverged from what the index row * claims. Defence against a tampered index table writing back to a post * with attacker-chosen URL content. * * @since 1.9.0 * @param string $content Raw post_content. * @param string $expected_uid Block UID to find. * @param string $expected_url URL the index row says the block points at. * @return bool */ private static function row_matches_post( string $content, string $expected_uid, string $expected_url ): bool { $found = false; $walk = static function ( array $blocks ) use ( &$walk, $expected_uid, $expected_url, &$found ): void { foreach ( $blocks as $b ) { if ( $found ) { return; } if ( ! is_array( $b ) ) { continue; } if ( ( $b['blockName'] ?? '' ) === 'cloudscale/code-block' ) { $attrs = $b['attrs'] ?? array(); if ( ( $attrs['githubBlockId'] ?? '' ) === $expected_uid && ( $attrs['githubUrl'] ?? '' ) === $expected_url ) { $found = true; return; } } if ( ! empty( $b['innerBlocks'] ) && is_array( $b['innerBlocks'] ) ) { $walk( $b['innerBlocks'] ); } } }; $walk( parse_blocks( $content ) ); return $found; } /** * Recursive helper for update_block_content_in_post. Mutates the parsed * block tree in place when it finds the matching `githubBlockId`. * * @since 1.9.0 * @param array &$blocks Parsed blocks (by reference for mutation). * @param string $uid Block UID to match. * @param string $new_content New content to install on the matching block. * @param int $synced_at Timestamp to stamp as the last sync. * @param string $sha SHA of the new content. * @param bool &$updated Flag set true when a match is found. * @return void */ private static function walk_and_update( array &$blocks, string $uid, string $new_content, int $synced_at, string $sha, bool &$updated ): void { foreach ( $blocks as &$b ) { if ( ! is_array( $b ) ) { continue; } if ( ( $b['blockName'] ?? '' ) === 'cloudscale/code-block' ) { $attrs = $b['attrs'] ?? array(); if ( ( $attrs['githubBlockId'] ?? '' ) === $uid ) { // Normalise legacy entity-encoded prefix/suffix to the raw // form the editor writes, so `content` is composed in ONE // canonical representation (raw code; the serializer's // <-style escaping keeps it comment- and kses-safe). $prefix = self::decode_legacy_entities( isset( $attrs['githubPrefix'] ) ? (string) $attrs['githubPrefix'] : '' ); $suffix = self::decode_legacy_entities( isset( $attrs['githubSuffix'] ) ? (string) $attrs['githubSuffix'] : '' ); $attrs['githubPrefix'] = $prefix; $attrs['githubSuffix'] = $suffix; $attrs['content'] = $prefix . $new_content . $suffix; $attrs['githubLastSync'] = $synced_at; $attrs['githubLastSha'] = $sha; $b['attrs'] = $attrs; $updated = true; } } if ( ! empty( $b['innerBlocks'] ) && is_array( $b['innerBlocks'] ) ) { self::walk_and_update( $b['innerBlocks'], $uid, $new_content, $synced_at, $sha, $updated ); } } unset( $b ); } public static function register_rest_routes(): void { register_rest_route( 'csdt/v1', '/github-sync', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'rest_sync' ), // Tightened from edit_posts to edit_post($post_id): the caller must // be able to edit the SPECIFIC post they're syncing into. Without // this, any Contributor could trigger arbitrary outbound HTTP // through the WP host. 'permission_callback' => static function ( \WP_REST_Request $request ) { $post_id = (int) $request->get_param( 'post_id' ); if ( $post_id <= 0 ) { return new \WP_Error( 'missing_post_id', 'post_id is required.', array( 'status' => 400 ) ); } if ( ! current_user_can( 'edit_post', $post_id ) ) { return new \WP_Error( 'rest_forbidden', 'You do not have permission to edit this post.', array( 'status' => 403 ) ); } return true; }, 'args' => array( 'url' => array( 'type' => 'string', 'required' => true, ), 'snippet' => array( 'type' => 'string', 'required' => false, 'default' => '', ), 'post_id' => array( 'type' => 'integer', 'required' => true, ), ), ) ); } /** * Per-user rate limit: cap GitHub-sync REST calls at 30/min/user. Returns * a WP_Error if the user is over the cap; returns null if the call is OK. */ private static function rest_rate_limit(): ?\WP_Error { $uid = get_current_user_id(); if ( $uid <= 0 ) { return null; } $key = 'csdt_ghsync_rl_' . $uid; $hits = (int) get_transient( $key ); if ( $hits >= 30 ) { return new \WP_Error( 'rate_limited', 'Too many GitHub-sync calls in the last minute. Slow down.', array( 'status' => 429 ) ); } set_transient( $key, $hits + 1, MINUTE_IN_SECONDS ); return null; } public static function rest_sync( \WP_REST_Request $request ) { $rl = self::rest_rate_limit(); if ( $rl ) { return $rl; } $url = (string) $request->get_param( 'url' ); $snippet = trim( (string) $request->get_param( 'snippet' ) ); $provider = CSDT_Git_Providers::detect( $url ); $parts = $provider ? $provider->parse( $url ) : null; if ( ! $provider || ! $parts ) { return new \WP_Error( 'invalid_url', 'Not a valid GitHub or GitLab blob URL.', array( 'status' => 400 ) ); } // URL hash snippet is a hint; the explicit snippet param wins when supplied. if ( $snippet === '' && $parts['snippet'] !== '' ) { $snippet = $parts['snippet']; } $fetch = self::fetch_url( $provider->raw_url( $parts ) ); if ( is_wp_error( $fetch ) ) { return $fetch; } $extracted = self::extract_snippet( $fetch['body'], $snippet ); if ( is_wp_error( $extracted ) ) { return $extracted; } // Anchor the view URL to the snippet's line range; whole-file links get no anchor. $anchor_start = ( $snippet !== '' && $extracted['lineStart'] > 0 ) ? $extracted['lineStart'] : 0; $view_url = $provider->view_url( $parts, $anchor_start, $extracted['lineEnd'] ); return new \WP_REST_Response( array( 'content' => $extracted['content'], 'sha' => $fetch['sha'], 'lastSync' => time(), 'lineStart' => $extracted['lineStart'], 'lineEnd' => $extracted['lineEnd'], 'language' => self::guess_language( $parts['path'] ), 'viewUrl' => $view_url, ), 200 ); } /** * Parses a GitHub /blob/ page URL. Returns null when the URL doesn't match. * Recognised hash forms: #snippet=NAME (preferred), #L42-L87 (informational). * * @deprecated 1.10.265 Use CSDT_Git_Providers::detect()->parse() for multi-provider * support (GitHub + GitLab). Kept for any external callers. */ public static function parse_github_url( string $url ): ?array { $url = trim( $url ); if ( ! preg_match( '~^https?://github\.com/([^/\s]+)/([^/\s]+)/blob/([^/\s]+)/([^#\s]+)(?:#(.*))?$~', $url, $m ) ) { return null; } $hash = $m[5] ?? ''; $snippet = ''; if ( preg_match( '/(?:^|[&;])snippet=([A-Za-z0-9_.\-]+)/', $hash, $sm ) ) { $snippet = $sm[1]; } return array( 'owner' => $m[1], 'repo' => $m[2], 'branch' => rawurldecode( $m[3] ), 'path' => rawurldecode( $m[4] ), 'snippet' => $snippet, ); } /** * Fetches a raw file body from a provider-built URL (unauthenticated, public * repos only). Returns [ 'body', 'sha' ] or a WP_Error. The URL must already * be a fully-formed raw endpoint from a CSDT_Git_Provider::raw_url() call. * * @since 1.10.265 * @param string $raw_url Fully-qualified raw-content URL. * @return array{body:string,sha:string}|\WP_Error */ public static function fetch_url( string $raw_url ) { // CSDT_Provider_GitLab deliberately matches ANY host (to support // self-hosted GitLab instances), so this is the SSRF choke point: // any Contributor+ who can edit a post could otherwise point this // server-side fetch at an internal/link-local address. Reuse the // plugin's shared SSRF guard rather than trusting the provider match. if ( ! class_exists( 'CSDT_Thumbnails' ) || ! CSDT_Thumbnails::is_safe_external_url( $raw_url ) ) { return new \WP_Error( 'unsafe_url', 'Source URL must be a publicly routable address.', array( 'status' => 400 ) ); } // GitHub's contents API answers with JSON unless the raw media type is asked for. $is_github_api = 'api.github.com' === strtolower( (string) wp_parse_url( $raw_url, PHP_URL_HOST ) ); $accept = $is_github_api ? 'application/vnd.github.raw' : 'text/plain'; $resp = wp_safe_remote_get( $raw_url, array( 'timeout' => 10, 'user-agent' => 'CloudScale-DevTools/GitSync', 'headers' => array( 'Accept' => $accept ), ) ); if ( is_wp_error( $resp ) ) { // Don't echo cURL/wp_http error messages back to callers, they can // leak internal DNS server IPs, proxy hostnames, and absolute paths // that disclose the WP host's outbound network topology. The raw // detail is still useful for debugging, so log it server-side when // debugging is enabled (avoids spamming a production error log). if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { CSDT_DevTools::log( '[CSDT GitSync] fetch_url failed: ' . CSDT_Error_Text::in_seconds( $resp->get_error_message() ) ); } return new \WP_Error( 'fetch_failed', 'Network error contacting the source repository.', array( 'status' => 502 ) ); } $code = wp_remote_retrieve_response_code( $resp ); if ( $code === 404 ) { return new \WP_Error( 'not_found', 'File not found (404). Check owner, repo, branch and path.', array( 'status' => 404 ) ); } // Unauthenticated API calls are limited per address per hour. Say so, and let the // sweep stop rather than spend the rest of the batch collecting the same refusal. if ( 429 === $code || ( 403 === $code && '0' === (string) wp_remote_retrieve_header( $resp, 'x-ratelimit-remaining' ) ) ) { self::$rate_limited = true; return new \WP_Error( 'rate_limited', 'The source repository is rate limiting this server. The stored copy is kept; it will be retried on the next sweep.', array( 'status' => 429 ) ); } if ( $code < 200 || $code >= 300 ) { return new \WP_Error( 'fetch_failed', 'The source repository returned an unexpected status code.', array( 'status' => 502 ) ); } $body = (string) wp_remote_retrieve_body( $resp ); // Normalise CRLF/CR to LF so marker matching is line-ending-agnostic. $body = str_replace( array( "\r\n", "\r" ), "\n", $body ); return array( 'body' => $body, 'sha' => hash( 'sha256', $body ), ); } /** * Extracts a snippet bounded by csdt:start name=NAME / csdt:end name=NAME markers * (in any comment style), strips the marker lines, and dedents the common leading * whitespace. Pass an empty $name to return the full body unchanged. * * Returns ['content','lineStart','lineEnd'] or WP_Error. */ public static function extract_snippet( string $body, string $name ) { $lines = explode( "\n", $body ); $total_lines = count( $lines ); if ( $name === '' ) { return array( 'content' => self::trim_trailing_blank_lines( $body ), 'lineStart' => 1, 'lineEnd' => $total_lines, ); } // Allow letters, digits, underscore, dot, hyphen in marker names. if ( ! preg_match( '/^[A-Za-z0-9_.\-]+$/', $name ) ) { return new \WP_Error( 'invalid_snippet_name', 'Snippet name contains invalid characters.', array( 'status' => 400 ) ); } $name_q = preg_quote( $name, '/' ); $start_p = '/\bcsdt:start\s+name=' . $name_q . '\b/'; $end_p = '/\bcsdt:end\s+name=' . $name_q . '\b/'; $start_idx = -1; $end_idx = -1; foreach ( $lines as $i => $line ) { if ( $start_idx === -1 && preg_match( $start_p, $line ) ) { $start_idx = $i; continue; } if ( $start_idx !== -1 && preg_match( $end_p, $line ) ) { $end_idx = $i; break; } } if ( $start_idx === -1 ) { return new \WP_Error( 'snippet_not_found', "Marker 'csdt:start name={$name}' was not found in the file.", array( 'status' => 404 ) ); } if ( $end_idx === -1 ) { return new \WP_Error( 'snippet_unterminated', "Marker 'csdt:end name={$name}' was not found after the start marker.", array( 'status' => 422 ) ); } $inner = array_slice( $lines, $start_idx + 1, $end_idx - $start_idx - 1 ); $inner = self::dedent_lines( $inner ); return array( 'content' => self::trim_trailing_blank_lines( implode( "\n", $inner ) ), 'lineStart' => $start_idx + 2, // 1-indexed, first content line (after start marker) 'lineEnd' => $end_idx, // 1-indexed, last content line (before end marker) ); } private static function dedent_lines( array $lines ): array { $min_indent = PHP_INT_MAX; foreach ( $lines as $line ) { if ( trim( $line ) === '' ) { continue; } // ignore blank lines for indent calc if ( preg_match( '/^([ \t]*)/', $line, $m ) ) { $indent = strlen( $m[1] ); if ( $indent < $min_indent ) { $min_indent = $indent; } } } if ( $min_indent === PHP_INT_MAX || $min_indent === 0 ) { return $lines; } return array_map( static fn( $line ) => substr( $line, $min_indent ) !== false ? substr( $line, $min_indent ) : $line, $lines ); } private static function trim_trailing_blank_lines( string $s ): string { return rtrim( $s, "\n" ); } private static function guess_language( string $path ): string { $basename = strtolower( basename( $path ) ); if ( $basename === 'dockerfile' || $basename === 'makefile' ) { return $basename; } $ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ); return self::EXT_TO_LANG[ $ext ] ?? ''; } }