'page', 'meta_key' => '_oribi_sync_source', 'numberposts' => 1, 'fields' => 'ids', ] ); if ( ! empty( $existing ) ) { $source = get_post_meta( $existing[0], '_oribi_sync_source', true ); // Extract the repo-path portion after the last colon (skip 'https:'). $colon = strrpos( $source, ':' ); if ( $colon !== false ) { $path_part = substr( $source, $colon + 1 ); // e.g. 'pages/about.php' // Validate it looks like a pages/ path before trusting it. if ( strncasecmp( $path_part, 'pages/', 6 ) === 0 ) { return substr( $path_part, 0, 6 ); // preserve original casing } } } return 'pages/'; } // ─── Generic authenticated request helpers ──────────────────────────────────── /** * Perform an authenticated POST request to the Git API. * * @param string $url Full API URL. * @param array $body Body payload (will be JSON-encoded). * @param string $provider Provider key. * @param string $pat Personal access token. * * @return array{code: int, body: array|string}|WP_Error */ function oribi_sync_api_post( string $url, array $body, string $provider, string $pat ) { return oribi_sync_api_request( 'POST', $url, $body, $provider, $pat ); } /** * Perform an authenticated PUT request to the Git API. * * @return array{code: int, body: array|string}|WP_Error */ function oribi_sync_api_put( string $url, array $body, string $provider, string $pat ) { return oribi_sync_api_request( 'PUT', $url, $body, $provider, $pat ); } /** * Perform an authenticated DELETE request to the Git API. * * @return array{code: int, body: array|string}|WP_Error */ function oribi_sync_api_delete( string $url, array $body, string $provider, string $pat ) { return oribi_sync_api_request( 'DELETE', $url, $body, $provider, $pat ); } /** * Internal: send an authenticated JSON request. * * @return array{code: int, body: array|string}|WP_Error */ function oribi_sync_api_request( string $method, string $url, array $body, string $provider, string $pat ) { $headers = array_merge( oribi_sync_auth_headers( $provider, $pat ), [ 'Content-Type' => 'application/json; charset=utf-8', 'Accept' => 'application/json', 'User-Agent' => 'Oribi-Sync-WP/' . ORIBI_SYNC_VERSION, ] ); // Ensure UTF-8 encoding of all body content array_walk_recursive( $body, function ( &$item ) { if ( is_string( $item ) && ! mb_check_encoding( $item, 'UTF-8' ) ) { $item = mb_convert_encoding( $item, 'UTF-8' ); } }); $args = [ 'method' => $method, 'timeout' => 30, 'headers' => $headers, 'body' => wp_json_encode( $body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ), ]; $response = wp_remote_request( $url, $args ); if ( is_wp_error( $response ) ) { return $response; } $code = wp_remote_retrieve_response_code( $response ); $raw_body = wp_remote_retrieve_body( $response ); $decoded = json_decode( $raw_body, true ); if ( json_last_error() !== JSON_ERROR_NONE ) { $decoded = $raw_body; } return [ 'code' => $code, 'body' => $decoded ]; } // ─── Gitea file metadata ────────────────────────────────────────────────────── /** * Get file metadata (including current SHA) from the Gitea API. * * @param string $api_base Gitea API base (e.g. https://host/api/v1/repos/owner/repo). * @param string $branch Branch name. * @param string $filepath Repo-relative file path. * @param string $pat Personal access token. * * @return array{sha: string, content: string}|null|WP_Error * null if file does not exist (404), WP_Error on failure. */ function oribi_sync_gitea_get_file_meta( string $api_base, string $branch, string $filepath, string $pat ) { $encoded_path = implode( '/', array_map( 'rawurlencode', explode( '/', $filepath ) ) ); $url = $api_base . '/contents/' . $encoded_path . '?ref=' . rawurlencode( $branch ); $result = oribi_sync_api_get( $url, 'gitea', $pat ); if ( is_wp_error( $result ) ) { $msg = $result->get_error_message(); // 404 means file doesn't exist yet — not an error if ( strpos( $msg, 'HTTP 404' ) !== false ) { return null; } return $result; } // Gitea inserts \n every 60 chars in base64 — strip before decoding. $raw_b64 = $result['content'] ?? ''; $content = ! empty( $raw_b64 ) ? base64_decode( str_replace( [ "\r", "\n", " " ], '', $raw_b64 ), true ) : ''; return [ 'sha' => $result['sha'] ?? '', 'content' => ( $content !== false ) ? $content : '', ]; } // ─── Gitea file create / update ─────────────────────────────────────────────── /** * Create or update a file in a Gitea repository. * * @param string $api_base API base URL. * @param string $branch Target branch. * @param string $filepath Repo-relative path. * @param string $content Raw file content (will be base64-encoded). * @param string $pat Personal access token. * @param string|null $sha Current file SHA (required for updates; null for creates). * @param string $message Commit message. * * @return array{code: int, body: array}|WP_Error */ function oribi_sync_gitea_put_file( string $api_base, string $branch, string $filepath, string $content, string $pat, ?string $sha = null, string $message = '' ) { // Validate and fix UTF-8 encoding before base64-encoding if ( ! mb_check_encoding( $content, 'UTF-8' ) ) { $content = mb_convert_encoding( $content, 'UTF-8', 'UTF-8, ISO-8859-1, Windows-1252' ); } $encoded_path = implode( '/', array_map( 'rawurlencode', explode( '/', $filepath ) ) ); $url = $api_base . '/contents/' . $encoded_path; $body = [ 'content' => base64_encode( $content ), 'branch' => $branch, 'message' => $message ?: 'Update ' . basename( $filepath ), ]; if ( $sha !== null ) { $body['sha'] = $sha; return oribi_sync_api_put( $url, $body, 'gitea', $pat ); } return oribi_sync_api_post( $url, $body, 'gitea', $pat ); } // ─── Gitea branch creation ─────────────────────────────────────────────────── /** * Create a new branch in a Gitea repository. * * @return array{code: int, body: array}|WP_Error */ function oribi_sync_gitea_create_branch( string $api_base, string $new_branch, string $base_branch, string $pat ) { $url = $api_base . '/branches'; return oribi_sync_api_post( $url, [ 'new_branch_name' => $new_branch, 'old_branch_name' => $base_branch, ], 'gitea', $pat ); } // ─── Gitea pull request ────────────────────────────────────────────────────── /** * Open a pull request in a Gitea repository. * * @return array{code: int, body: array}|WP_Error */ function oribi_sync_gitea_create_pr( string $api_base, string $head, string $base, string $title, string $body_text, string $pat ) { $url = $api_base . '/pulls'; return oribi_sync_api_post( $url, [ 'title' => $title, 'body' => $body_text, 'head' => $head, 'base' => $base, ], 'gitea', $pat ); } // ─── PHP page-data wrapper generation ───────────────────────────────────────── /** * Generate a PHP page-data wrapper suitable for the repo's page-data convention. * * The wrapper uses a nowdoc return so the file can be `include`d by * oribi_sync_execute_php() and produce the Gutenberg block HTML. * * @param string $content Gutenberg block HTML (from post_content). * @param string $slug Page slug. * @param string $title Page title. * * @return string PHP source code. */ function oribi_sync_generate_php_wrapper( string $content, string $slug, string $title = '' ): string { if ( empty( $title ) ) { $title = oribi_sync_slug_to_title( $slug ); } // Use a nowdoc so the content is treated as a literal string (no interpolation). $delimiter = 'ORIBI_SYNC_CONTENT'; $safe_content = str_replace( "\n" . $delimiter, "\n " . $delimiter, $content ); $php = "post_type !== 'page' ) { return [ 'ok' => false, 'action' => 'error', 'message' => 'Post not found or not a page.' ]; } // ── Settings ────────────────────────────────────────────────────────── $repo_url = get_option( 'oribi_sync_repo', '' ); $branch = get_option( 'oribi_sync_branch', 'main' ) ?: 'main'; $pat = oribi_sync_get_pat(); $provider = oribi_sync_get_provider(); if ( empty( $repo_url ) || empty( $pat ) ) { return [ 'ok' => false, 'action' => 'error', 'message' => 'Repository URL or PAT not configured.' ]; } if ( $provider !== 'gitea' ) { return [ 'ok' => false, 'action' => 'error', 'message' => 'Push is currently supported for Gitea / Forgejo only.' ]; } $parsed = oribi_sync_parse_repo_url( $repo_url ); if ( is_wp_error( $parsed ) ) { return [ 'ok' => false, 'action' => 'error', 'message' => $parsed->get_error_message() ]; } $api_base = oribi_sync_api_base( $provider, $parsed ); // ── Determine repo path ─────────────────────────────────────────────── $source_meta = get_post_meta( $post_id, '_oribi_sync_source', true ); $repo_path = ''; if ( ! empty( $source_meta ) ) { // Format: {repo_url}@{branch}:{path} // Use strrpos to find the LAST colon (skips the one in 'https:'). $colon_pos = strrpos( $source_meta, ':' ); if ( $colon_pos !== false ) { $at_pos = strrpos( substr( $source_meta, 0, $colon_pos ), '@' ); if ( $at_pos !== false ) { $candidate = substr( $source_meta, $colon_pos + 1 ); // Validate: path must start with 'pages/' (case-insensitive). // Discard corrupted values left by earlier bugs. if ( strncasecmp( $candidate, 'pages/', 6 ) === 0 ) { $repo_path = $candidate; } } } } if ( empty( $repo_path ) ) { // Derive from slug — files live under pages/ $repo_path = 'pages/' . $post->post_name . '.php'; } // ── Generate content ────────────────────────────────────────────────── $slug = $post->post_name; $title = $post->post_title; $wp_content = $post->post_content; $commit_msg = $opts['message'] ?? "Sync: update {$slug} from WordPress"; // ── Fetch remote file metadata ──────────────────────────────────────── $remote = oribi_sync_gitea_get_file_meta( $api_base, $branch, $repo_path, $pat ); if ( is_wp_error( $remote ) ) { return [ 'ok' => false, 'action' => 'error', 'message' => 'Failed to check remote file: ' . $remote->get_error_message() ]; } // Build PHP source: preserve original header for existing files, fresh wrapper for new ones. if ( $remote !== null && ! empty( $remote['content'] ) ) { $php_source = oribi_sync_replace_php_body( $remote['content'], $wp_content, $slug, $title ); } else { $php_source = oribi_sync_generate_php_wrapper( $wp_content, $slug, $title ); } $new_checksum = hash( 'sha256', $php_source ); $stored_sha = get_post_meta( $post_id, '_oribi_sync_git_sha', true ); // ── Decide strategy ─────────────────────────────────────────────────── if ( $remote === null ) { // File doesn't exist in repo → create it directly $result = oribi_sync_gitea_put_file( $api_base, $branch, $repo_path, $php_source, $pat, null, $commit_msg ); if ( is_wp_error( $result ) ) { return [ 'ok' => false, 'action' => 'error', 'message' => 'Create failed: ' . $result->get_error_message() ]; } if ( $result['code'] < 200 || $result['code'] >= 300 ) { $err = is_array( $result['body'] ) ? ( $result['body']['message'] ?? wp_json_encode( $result['body'] ) ) : $result['body']; return [ 'ok' => false, 'action' => 'error', 'message' => "Create failed (HTTP {$result['code']}): {$err}" ]; } // Update post meta $new_sha = $result['body']['content']['sha'] ?? ''; oribi_sync_update_push_meta( $post_id, $new_sha, $new_checksum, $repo_url, $branch, $repo_path ); oribi_sync_log_push( $slug, 'created', $branch ); return [ 'ok' => true, 'action' => 'created', 'message' => "Created {$repo_path} on branch {$branch}." ]; } $remote_sha = $remote['sha']; // Check for conflict: remote SHA differs from what we last synced $has_conflict = ! empty( $stored_sha ) && $remote_sha !== $stored_sha; if ( $has_conflict ) { // ── Conflict path: branch + PR ──────────────────────────────────── $timestamp = gmdate( 'Ymd-His' ); $new_branch = 'oribi-sync/' . $slug . '-' . $timestamp; // Create branch $branch_result = oribi_sync_gitea_create_branch( $api_base, $new_branch, $branch, $pat ); if ( is_wp_error( $branch_result ) ) { return [ 'ok' => false, 'action' => 'error', 'message' => 'Branch creation failed: ' . $branch_result->get_error_message() ]; } if ( $branch_result['code'] < 200 || $branch_result['code'] >= 300 ) { $err = is_array( $branch_result['body'] ) ? ( $branch_result['body']['message'] ?? '' ) : $branch_result['body']; return [ 'ok' => false, 'action' => 'error', 'message' => "Branch creation failed (HTTP {$branch_result['code']}): {$err}" ]; } // Fetch file meta on the new branch (same SHA as base branch initially) $branch_remote = oribi_sync_gitea_get_file_meta( $api_base, $new_branch, $repo_path, $pat ); $branch_sha = null; if ( ! is_wp_error( $branch_remote ) && $branch_remote !== null ) { $branch_sha = $branch_remote['sha']; } // Commit to the new branch $put_result = oribi_sync_gitea_put_file( $api_base, $new_branch, $repo_path, $php_source, $pat, $branch_sha, $commit_msg ); if ( is_wp_error( $put_result ) ) { return [ 'ok' => false, 'action' => 'error', 'message' => 'Commit to branch failed: ' . $put_result->get_error_message() ]; } if ( $put_result['code'] < 200 || $put_result['code'] >= 300 ) { $err = is_array( $put_result['body'] ) ? ( $put_result['body']['message'] ?? '' ) : $put_result['body']; return [ 'ok' => false, 'action' => 'error', 'message' => "Commit to branch failed (HTTP {$put_result['code']}): {$err}" ]; } // Open PR $pr_title = "Sync: {$slug}"; $pr_body = "Automatic push from WordPress (Oribi Tech Sync).\n\n"; $pr_body .= "**Page:** {$title} (`{$slug}`)\n"; $pr_body .= "**Commit:** {$commit_msg}\n\n"; $pr_body .= "The target branch `{$branch}` has been modified since the last sync, "; $pr_body .= "so this change was pushed to `{$new_branch}` for review.\n\n"; $pr_body .= "Stored SHA: `{$stored_sha}`\n"; $pr_body .= "Remote SHA: `{$remote_sha}`\n"; $pr_result = oribi_sync_gitea_create_pr( $api_base, $new_branch, $branch, $pr_title, $pr_body, $pat ); if ( is_wp_error( $pr_result ) ) { return [ 'ok' => false, 'action' => 'error', 'message' => 'PR creation failed: ' . $pr_result->get_error_message() ]; } $pr_url = ''; if ( $pr_result['code'] >= 200 && $pr_result['code'] < 300 && is_array( $pr_result['body'] ) ) { $pr_url = $pr_result['body']['html_url'] ?? ''; } // Save PR URL on the post if ( ! empty( $pr_url ) ) { update_post_meta( $post_id, '_oribi_sync_pr_url', $pr_url ); } oribi_sync_log_push( $slug, 'pr_created', $new_branch, $pr_url ); return [ 'ok' => true, 'action' => 'pr_created', 'message' => "Conflict detected — created PR on branch {$new_branch}.", 'pr_url' => $pr_url, ]; } // ── No conflict: direct update ──────────────────────────────────────── $result = oribi_sync_gitea_put_file( $api_base, $branch, $repo_path, $php_source, $pat, $remote_sha, $commit_msg ); if ( is_wp_error( $result ) ) { return [ 'ok' => false, 'action' => 'error', 'message' => 'Update failed: ' . $result->get_error_message() ]; } if ( $result['code'] < 200 || $result['code'] >= 300 ) { $err = is_array( $result['body'] ) ? ( $result['body']['message'] ?? wp_json_encode( $result['body'] ) ) : $result['body']; return [ 'ok' => false, 'action' => 'error', 'message' => "Update failed (HTTP {$result['code']}): {$err}" ]; } // Update post meta $new_sha = $result['body']['content']['sha'] ?? ''; oribi_sync_update_push_meta( $post_id, $new_sha, $new_checksum, $repo_url, $branch, $repo_path ); oribi_sync_log_push( $slug, 'updated', $branch ); return [ 'ok' => true, 'action' => 'updated', 'message' => "Updated {$repo_path} on branch {$branch}." ]; } /** * Bulk-push all synced pages. * * @return array{ok: bool, results: array} */ function oribi_sync_push_all(): array { $query = new WP_Query( [ 'post_type' => 'page', 'post_status' => 'publish', 'meta_key' => '_oribi_sync_checksum', 'posts_per_page' => -1, 'fields' => 'ids', ] ); $results = []; foreach ( $query->posts as $post_id ) { $page = get_post( $post_id ); $slug = $page ? $page->post_name : "#{$post_id}"; $result = oribi_sync_push_page( (int) $post_id ); $results[] = array_merge( $result, [ 'slug' => $slug, 'post_id' => $post_id ] ); } $all_ok = ! empty( $results ) && count( array_filter( $results, fn( $r ) => ! $r['ok'] ) ) === 0; return [ 'ok' => $all_ok, 'results' => $results ]; } // ─── Post meta helpers ──────────────────────────────────────────────────────── /** * Update post meta after a successful push. */ function oribi_sync_update_push_meta( int $post_id, string $sha, string $checksum, string $repo_url, string $branch, string $path ): void { if ( ! empty( $sha ) ) { update_post_meta( $post_id, '_oribi_sync_git_sha', $sha ); } update_post_meta( $post_id, '_oribi_sync_checksum', $checksum ); update_post_meta( $post_id, '_oribi_sync_source', $repo_url . '@' . $branch . ':' . $path ); update_post_meta( $post_id, '_oribi_sync_last_push', current_time( 'mysql' ) ); // Clear any stale PR URL on successful direct push delete_post_meta( $post_id, '_oribi_sync_pr_url' ); } // ─── Push log ───────────────────────────────────────────────────────────────── /** * Append an entry to the push log. */ function oribi_sync_log_push( string $slug, string $action, string $branch, string $pr_url = '' ): void { $log = get_option( 'oribi_sync_push_log', [] ); if ( ! is_array( $log ) ) $log = []; array_unshift( $log, [ 'time' => current_time( 'mysql' ), 'slug' => $slug, 'action' => $action, 'branch' => $branch, 'pr_url' => $pr_url, ] ); // Keep last 50 entries $log = array_slice( $log, 0, 50 ); update_option( 'oribi_sync_push_log', $log, 'no' ); }