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.
This commit is contained in:
408
includes/api-client.php
Normal file
408
includes/api-client.php
Normal file
@@ -0,0 +1,408 @@
|
||||
<?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;
|
||||
}
|
||||
Reference in New Issue
Block a user