Files
WordpressGitSync/includes/api-client.php
Matt Batchelder f17b9ccb98 Add Oribi Sync plugin for syncing WordPress pages and theme files from a Git repository
- Implement encryption helpers for storing and retrieving the Personal Access Token (PAT).
- Create REST API endpoints for triggering sync, checking sync status, and handling webhooks.
- Develop the sync engine to fetch pages from the Git repository, create/update WordPress pages, and trash removed pages.
- Add functionality for previewing and applying theme files from the repository.
- Set up plugin activation and deactivation hooks to manage default options and scheduled tasks.
- Implement uninstall routine to clean up plugin options and metadata from posts.
2026-02-19 16:05:43 -05:00

409 lines
16 KiB
PHP

<?php
/**
* Oribi Sync — Git provider API client.
*
* Supports GitHub, GitLab, Bitbucket Cloud, Gitea / Forgejo,
* and Azure DevOps REST APIs.
* Fetches repository tree and file contents using PAT authentication.
*/
if ( ! defined( 'ABSPATH' ) ) exit;
// ─── Supported providers ──────────────────────────────────────────────────────
/**
* Return the list of supported providers for the admin dropdown.
*/
function oribi_sync_providers(): array {
return [
'github' => 'GitHub',
'gitlab' => 'GitLab',
'bitbucket' => 'Bitbucket Cloud',
'gitea' => 'Gitea / Forgejo',
'azure' => 'Azure DevOps',
];
}
/**
* Get the configured provider (stored in options).
*/
function oribi_sync_get_provider(): string {
$provider = get_option( 'oribi_sync_provider', '' );
if ( ! empty( $provider ) && array_key_exists( $provider, oribi_sync_providers() ) ) {
return $provider;
}
// Auto-detect fallback for existing installs without the option
$repo = get_option( 'oribi_sync_repo', '' );
return oribi_sync_detect_provider( $repo );
}
/**
* Auto-detect the Git provider from the repo URL.
* Used as fallback when no explicit provider is set.
*
* @return string Provider key.
*/
function oribi_sync_detect_provider( string $repo_url ): string {
if ( stripos( $repo_url, 'github.com' ) !== false ) return 'github';
if ( stripos( $repo_url, 'gitlab.com' ) !== false ) return 'gitlab';
if ( stripos( $repo_url, 'gitlab' ) !== false ) return 'gitlab';
if ( stripos( $repo_url, 'bitbucket.org' ) !== false ) return 'bitbucket';
if ( stripos( $repo_url, 'dev.azure.com' ) !== false ) return 'azure';
if ( stripos( $repo_url, 'visualstudio.com' ) !== false ) return 'azure';
// Assume Gitea for unknown self-hosted (most compatible generic API)
return 'gitea';
}
// ─── URL parsing ──────────────────────────────────────────────────────────────
/**
* Parse owner/repo (and optionally project) from a Git URL.
*
* Accepts HTTPS and SSH styles for all providers.
*
* @return array{owner: string, repo: string, host?: string, project?: string}|WP_Error
*/
function oribi_sync_parse_repo_url( string $url ) {
// Azure DevOps: https://dev.azure.com/{org}/{project}/_git/{repo}
if ( preg_match( '#https?://dev\.azure\.com/([^/]+)/([^/]+)/_git/([^/\s]+?)(?:\.git)?$#i', $url, $m ) ) {
return [ 'owner' => $m[1], 'project' => $m[2], 'repo' => $m[3], 'host' => 'dev.azure.com' ];
}
// Azure DevOps legacy: https://{org}.visualstudio.com/{project}/_git/{repo}
if ( preg_match( '#https?://([^.]+)\.visualstudio\.com/([^/]+)/_git/([^/\s]+?)(?:\.git)?$#i', $url, $m ) ) {
return [ 'owner' => $m[1], 'project' => $m[2], 'repo' => $m[3], 'host' => $m[1] . '.visualstudio.com' ];
}
// Generic HTTPS: https://host/owner/repo
if ( preg_match( '#https?://([^/]+)/([^/]+)/([^/\s.]+?)(?:\.git)?(?:/)?$#i', $url, $m ) ) {
return [ 'owner' => $m[2], 'repo' => $m[3], 'host' => $m[1] ];
}
// SSH: git@host:owner/repo.git
if ( preg_match( '#[^@]+@([^:]+):([^/]+)/([^/\s.]+?)(?:\.git)?$#i', $url, $m ) ) {
return [ 'owner' => $m[2], 'repo' => $m[3], 'host' => $m[1] ];
}
return new WP_Error( 'oribi_sync_bad_url', 'Could not parse owner/repo from the repository URL.' );
}
// ─── API base URLs ────────────────────────────────────────────────────────────
/**
* Build the base API URL for the configured provider.
*/
function oribi_sync_api_base( string $provider, array $parsed ): string {
$owner = $parsed['owner'];
$repo = $parsed['repo'];
$host = $parsed['host'] ?? '';
switch ( $provider ) {
case 'gitlab':
$api_host = ( $host && $host !== 'gitlab.com' ) ? $host : 'gitlab.com';
$encoded = rawurlencode( $owner . '/' . $repo );
return "https://{$api_host}/api/v4/projects/{$encoded}";
case 'bitbucket':
return "https://api.bitbucket.org/2.0/repositories/{$owner}/{$repo}";
case 'azure':
$project = $parsed['project'] ?? $repo;
$org = $owner;
return "https://dev.azure.com/{$org}/{$project}/_apis/git/repositories/{$repo}";
case 'gitea':
// Works for Gitea, Forgejo, and most Gitea-compatible hosts
$api_host = $host ?: 'localhost:3000';
return "https://{$api_host}/api/v1/repos/{$owner}/{$repo}";
case 'github':
default:
$api_host = ( $host && $host !== 'github.com' ) ? $host . '/api/v3' : 'api.github.com';
return "https://{$api_host}/repos/{$owner}/{$repo}";
}
}
// ─── Auth headers ─────────────────────────────────────────────────────────────
/**
* Build authorization headers for the provider.
*
* @return array Headers array to merge into request.
*/
function oribi_sync_auth_headers( string $provider, string $pat ): array {
switch ( $provider ) {
case 'bitbucket':
// Bitbucket Cloud app passwords use Basic auth (username:app_password)
// If PAT contains ':', treat as username:password; otherwise Bearer
if ( strpos( $pat, ':' ) !== false ) {
return [ 'Authorization' => 'Basic ' . base64_encode( $pat ) ];
}
return [ 'Authorization' => 'Bearer ' . $pat ];
case 'azure':
// Azure DevOps PATs use Basic auth with empty username
return [ 'Authorization' => 'Basic ' . base64_encode( ':' . $pat ) ];
case 'gitlab':
return [ 'PRIVATE-TOKEN' => $pat ];
case 'gitea':
return [ 'Authorization' => 'token ' . $pat ];
case 'github':
default:
return [ 'Authorization' => 'Bearer ' . $pat ];
}
}
// ─── API request ──────────────────────────────────────────────────────────────
/**
* Perform an authenticated GET request to the Git API.
*
* @return array|WP_Error Decoded JSON body or WP_Error.
*/
function oribi_sync_api_get( string $url, string $provider, string $pat ) {
$headers = array_merge(
oribi_sync_auth_headers( $provider, $pat ),
[
'Accept' => 'application/json',
'User-Agent' => 'Oribi-Sync-WP/' . ORIBI_SYNC_VERSION,
]
);
// Provider-specific Accept overrides
if ( $provider === 'github' ) {
$headers['Accept'] = 'application/vnd.github+json';
} elseif ( $provider === 'azure' ) {
$headers['Accept'] = 'application/json';
}
$response = wp_remote_get( $url, [
'timeout' => 30,
'headers' => $headers,
] );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = wp_remote_retrieve_response_code( $response );
$body = wp_remote_retrieve_body( $response );
if ( $code < 200 || $code >= 300 ) {
return new WP_Error(
'oribi_sync_api_error',
sprintf( 'Git API returned HTTP %d: %s', $code, wp_trim_words( $body, 30, '…' ) )
);
}
$decoded = json_decode( $body, true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
return new WP_Error( 'oribi_sync_json_error', 'Failed to parse API JSON response.' );
}
return $decoded;
}
// ─── Tree fetching ────────────────────────────────────────────────────────────
/**
* Fetch the repository tree (recursive) for the given branch.
*
* Returns a flat list of file entries with 'path' and 'type'.
*
* @return array|WP_Error
*/
function oribi_sync_fetch_tree( string $api_base, string $branch, string $provider, string $pat ) {
switch ( $provider ) {
// ── GitLab ──────────────────────────────────────────────────────
case 'gitlab':
$entries = [];
$page = 1;
do {
$url = $api_base . '/repository/tree?' . http_build_query( [
'ref' => $branch,
'recursive' => 'true',
'per_page' => 100,
'page' => $page,
] );
$result = oribi_sync_api_get( $url, $provider, $pat );
if ( is_wp_error( $result ) ) return $result;
if ( empty( $result ) ) break;
foreach ( $result as $item ) {
$entries[] = [
'path' => $item['path'],
'type' => $item['type'] === 'blob' ? 'blob' : $item['type'],
'sha' => $item['id'] ?? '', // GitLab uses 'id' for blob SHA
];
}
$page++;
} while ( count( $result ) === 100 );
return $entries;
// ── Bitbucket Cloud ─────────────────────────────────────────────
case 'bitbucket':
$entries = [];
$url = $api_base . '/src/' . rawurlencode( $branch ) . '/?pagelen=100&max_depth=10';
// Bitbucket returns directory listings; we recurse via 'next' links
while ( $url ) {
$result = oribi_sync_api_get( $url, $provider, $pat );
if ( is_wp_error( $result ) ) return $result;
foreach ( $result['values'] ?? [] as $item ) {
if ( ( $item['type'] ?? '' ) === 'commit_file' ) {
$entries[] = [ 'path' => $item['path'], 'type' => 'blob' ];
}
}
$url = $result['next'] ?? null;
}
return $entries;
// ── Azure DevOps ───────────────────────────────────────────────
case 'azure':
$url = $api_base . '/items?' . http_build_query( [
'recursionLevel' => 'full',
'versionDescriptor.version' => $branch,
'versionDescriptor.versionType' => 'branch',
'api-version' => '7.0',
] );
$result = oribi_sync_api_get( $url, $provider, $pat );
if ( is_wp_error( $result ) ) return $result;
$entries = [];
foreach ( $result['value'] ?? [] as $item ) {
if ( ! $item['isFolder'] ) {
// Azure paths start with '/' — strip leading slash
$path = ltrim( $item['path'], '/' );
$entries[] = [ 'path' => $path, 'type' => 'blob' ];
}
}
return $entries;
// ── Gitea / Forgejo ─────────────────────────────────────────────
case 'gitea':
$url = $api_base . '/git/trees/' . rawurlencode( $branch ) . '?recursive=true';
$result = oribi_sync_api_get( $url, $provider, $pat );
if ( is_wp_error( $result ) ) return $result;
if ( ! isset( $result['tree'] ) ) {
return new WP_Error( 'oribi_sync_tree_error', 'Unexpected tree response from Gitea.' );
}
return array_map( function ( $item ) {
return [ 'path' => $item['path'], 'type' => $item['type'], 'sha' => $item['sha'] ?? '' ];
}, $result['tree'] );
// ── GitHub (default) ───────────────────────────────────────────
case 'github':
default:
$url = $api_base . '/git/trees/' . rawurlencode( $branch ) . '?recursive=1';
$result = oribi_sync_api_get( $url, $provider, $pat );
if ( is_wp_error( $result ) ) return $result;
if ( ! isset( $result['tree'] ) ) {
return new WP_Error( 'oribi_sync_tree_error', 'Unexpected tree response structure.' );
}
return array_map( function ( $item ) {
return [ 'path' => $item['path'], 'type' => $item['type'], 'sha' => $item['sha'] ?? '' ];
}, $result['tree'] );
}
}
// ─── File fetching ────────────────────────────────────────────────────────────
/**
* Fetch raw file content from the repository.
*
* @return string|WP_Error Raw file content.
*/
function oribi_sync_fetch_file( string $api_base, string $branch, string $file_path, string $provider, string $pat ) {
$encoded_path = implode( '/', array_map( 'rawurlencode', explode( '/', $file_path ) ) );
switch ( $provider ) {
case 'gitlab':
$url = $api_base . '/repository/files/' . rawurlencode( $file_path ) . '/raw?' . http_build_query( [ 'ref' => $branch ] );
$accept = 'text/plain';
break;
case 'bitbucket':
$url = $api_base . '/src/' . rawurlencode( $branch ) . '/' . $encoded_path;
$accept = 'text/plain';
break;
case 'azure':
$url = $api_base . '/items?' . http_build_query( [
'path' => '/' . $file_path,
'versionDescriptor.version' => $branch,
'versionDescriptor.versionType' => 'branch',
'api-version' => '7.0',
'\$format' => 'octetStream',
] );
$accept = 'application/octet-stream';
break;
case 'gitea':
$url = $api_base . '/raw/' . $encoded_path . '?ref=' . rawurlencode( $branch );
$accept = 'text/plain';
break;
case 'github':
default:
$url = $api_base . '/contents/' . $encoded_path . '?ref=' . rawurlencode( $branch );
$accept = 'application/vnd.github.raw+json';
break;
}
$headers = array_merge(
oribi_sync_auth_headers( $provider, $pat ),
[
'Accept' => $accept,
'User-Agent' => 'Oribi-Sync-WP/' . ORIBI_SYNC_VERSION,
]
);
$response = wp_remote_get( $url, [
'timeout' => 30,
'headers' => $headers,
] );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = wp_remote_retrieve_response_code( $response );
if ( $code < 200 || $code >= 300 ) {
return new WP_Error(
'oribi_sync_file_error',
sprintf( 'Failed to fetch %s (HTTP %d)', $file_path, $code )
);
}
return wp_remote_retrieve_body( $response );
}
// ─── Tree filtering ───────────────────────────────────────────────────────────
/**
* Filter tree entries to only those under a given directory prefix.
*
* @param array $tree Tree from oribi_sync_fetch_tree().
* @param string $prefix Directory prefix (e.g. 'pages/').
* @param bool $recursive Whether to include files in subdirectories (default: false).
*
* @return array Filtered entries (blobs only).
*/
function oribi_sync_filter_tree( array $tree, string $prefix, bool $recursive = false ): array {
$prefix = rtrim( $prefix, '/' ) . '/';
$out = [];
foreach ( $tree as $entry ) {
if ( $entry['type'] !== 'blob' ) continue;
if ( strpos( $entry['path'], $prefix ) !== 0 ) continue;
$relative = substr( $entry['path'], strlen( $prefix ) );
// Skip sub-directory files unless recursive is enabled
if ( ! $recursive && strpos( $relative, '/' ) !== false ) continue;
$out[] = $entry;
}
return $out;
}