Files
WordpressGitSync/includes/push-client.php

597 lines
25 KiB
PHP

<?php
/**
* Oribi Sync — Push client.
*
* Writes page content back to the Git repository.
* On SHA conflict (remote file changed since last sync), creates a branch
* named oribi-sync/{slug}-{timestamp} and opens a pull request.
*
* Currently supports Gitea / Forgejo.
*/
if ( ! defined( 'ABSPATH' ) ) exit;
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Detect the actual casing of the Pages directory in the repo tree.
*
* Looks at existing synced pages for a stored repo path, extracts the
* directory prefix. Falls back to 'Pages/' if nothing found.
*/
function oribi_sync_detect_pages_prefix(): string {
// Check post meta of any previously-synced page for the real path
$existing = get_posts( [
'post_type' => '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 = "<?php\n";
$php .= "/*\n";
$php .= " * Title: {$title}\n";
$php .= " * Slug: {$slug}\n";
$php .= " * Post Type: page\n";
$php .= " */\n\n";
$php .= "return <<<'{$delimiter}'\n";
$php .= $safe_content . "\n";
$php .= $delimiter . ";\n";
return $php;
}
/**
* Replace the content body in an existing PHP page-data file.
*
* Preserves the original header (everything before the `return` statement)
* and only replaces the body between the heredoc / nowdoc delimiters.
* If the file format can't be parsed, falls back to generating a new wrapper.
*
* @param string $existing_source Current PHP source from the repo.
* @param string $new_content New Gutenberg block HTML.
* @param string $slug Page slug (used for fallback wrapper).
* @param string $title Page title (used for fallback wrapper).
*
* @return string Updated PHP source code.
*/
function oribi_sync_replace_php_body( string $existing_source, string $new_content, string $slug, string $title ): string {
// Match: return <<<'DELIMITER' or return <<<DELIMITER (heredoc / nowdoc)
if ( preg_match( '/^(.*?return\s+<<<\'?)(\w+)(\'?\s*\n)(.*)(\n\2;?\s*)$/s', $existing_source, $m ) ) {
$header = $m[1]; // everything up to and including "return <<<"
$delimiter = $m[2]; // e.g. ORIBI_SYNC_CONTENT
$quote_end = $m[3]; // closing quote + newline
$suffix = $m[5]; // closing delimiter + semicolon
// Escape content if it contains the delimiter string on its own line
$safe_content = str_replace( "\n" . $delimiter, "\n " . $delimiter, $new_content );
return $header . $delimiter . $quote_end . $safe_content . $suffix;
}
// Couldn't parse the existing file — fall back to a fresh wrapper.
return oribi_sync_generate_php_wrapper( $new_content, $slug, $title );
}
// ─── Push orchestrator ───────────────────────────────────────────────────────
/**
* Push a single WordPress page back to the Git repository.
*
* Flow:
* 1. Derive repo path from post meta (_oribi_sync_source) or settings.
* 2. Generate PHP wrapper from page content.
* 3. Fetch remote file SHA from the Gitea contents API.
* 4. If remote SHA matches stored SHA → direct update (no conflict).
* 5. If remote SHA differs (file changed externally) → create branch + PR.
* 6. If file doesn't exist → create it on the target branch.
* 7. Update post meta on success.
*
* @param int $post_id WP post ID.
* @param array $opts Optional overrides: 'message' (commit message).
*
* @return array{ok: bool, action: string, message: string, pr_url?: string}
*/
function oribi_sync_push_page( int $post_id, array $opts = [] ): array {
$post = get_post( $post_id );
if ( ! $post || $post->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;
// Read post_content directly from the DB — bypassing every get_post()
// filter — so we get exactly what oribi_sync_save_post() wrote.
global $wpdb;
$wp_content = (string) $wpdb->get_var(
$wpdb->prepare( 'SELECT post_content FROM ' . $wpdb->posts . ' WHERE ID = %d', $post_id )
);
// Clean any corruption baked in by previous syncs (e.g. \u0026amp; artefacts)
$wp_content = oribi_sync_clean_block_content( $wp_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' );
}