From f17b9ccb98aaf3c9a57ed051dacbc827858497c6 Mon Sep 17 00:00:00 2001 From: Matt Batchelder Date: Thu, 19 Feb 2026 16:05:43 -0500 Subject: [PATCH] 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. --- .vscode/tasks.json | 23 ++ README.md | 139 +++++++++++++ assets/admin.css | 37 ++++ dist/oribi-tech-sync.zip | Bin 0 -> 21650 bytes includes/admin.php | 265 +++++++++++++++++++++++ includes/api-client.php | 408 ++++++++++++++++++++++++++++++++++++ includes/crypto.php | 70 +++++++ includes/rest.php | 105 ++++++++++ includes/sync-engine.php | 417 +++++++++++++++++++++++++++++++++++++ includes/theme-preview.php | 233 +++++++++++++++++++++ oribi-tech-sync.php | 51 +++++ uninstall.php | 36 ++++ 12 files changed, 1784 insertions(+) create mode 100644 .vscode/tasks.json create mode 100644 README.md create mode 100644 assets/admin.css create mode 100644 dist/oribi-tech-sync.zip create mode 100644 includes/admin.php create mode 100644 includes/api-client.php create mode 100644 includes/crypto.php create mode 100644 includes/rest.php create mode 100644 includes/sync-engine.php create mode 100644 includes/theme-preview.php create mode 100644 oribi-tech-sync.php create mode 100644 uninstall.php diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..44cd847 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,23 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Package Plugin (zip)", + "type": "shell", + "command": "mkdir -p \"${workspaceFolder}/dist\" /tmp/oribi-tech-sync && rsync -a --exclude='.git' --exclude='.vscode' --exclude='dist' --exclude='*.zip' --exclude='.DS_Store' --exclude='node_modules' \"${workspaceFolder}/\" /tmp/oribi-tech-sync/ && cd /tmp && zip -r \"${workspaceFolder}/dist/oribi-tech-sync.zip\" oribi-tech-sync && rm -rf /tmp/oribi-tech-sync && echo \"✓ Created dist/oribi-tech-sync.zip\"", + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": false, + "clear": true + }, + "problemMatcher": [] + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ab30c0 --- /dev/null +++ b/README.md @@ -0,0 +1,139 @@ +# Oribi Tech Sync + +WordPress plugin that syncs pages and theme files from a remote Git repository. + +## Features + +- **Page sync** — Reads Gutenberg HTML files from the repo's `pages/` directory and creates/updates WordPress pages automatically. +- **Theme file preview & apply** — Fetches files from the repo's `theme/` directory, shows a side-by-side preview against the active theme, and lets an admin selectively apply changes. +- **Encrypted PAT storage** — Personal Access Tokens are stored encrypted (AES-256-CBC) in the database with `autoload=false`. +- **Dry-run mode** — Preview what a sync would do without making any changes. +- **Sync log** — Keeps a history of the last 20 syncs with details on created, updated, trashed, and skipped pages. +- **REST API & webhook** — Trigger syncs programmatically or via Git host webhooks. +- **Trash policy** — Pages removed from the repo are moved to Trash for manual review. + +## Repository Layout + +The plugin expects the following structure in the remote Git repository: + +``` +repo/ +├── pages/ +│ ├── home.php +│ ├── about.php +│ ├── contact.php +│ ├── managed-it.php +│ └── ... +├── theme/ +│ ├── style.css +│ ├── theme.json +│ └── assets/ +│ ├── css/ +│ │ └── main.css +│ └── js/ +│ └── main.js +└── (other files — ignored) +``` + +### `pages/` directory +- **PHP files** (`.php`) — Use the `oribi_b()`, `oribi_b_open()`, and `oribi_b_close()` block helpers to build Gutenberg markup and `return` the result (same format as the theme's `page-data/*.php` files). Requires the **Oribi Tech Setup** plugin to be active for the helper functions. +- **HTML files** (`.html`) — Contain raw Gutenberg block markup (``) and are used directly as page content. +- The filename (without extension) becomes the page slug: `home.php` → slug `home`. +- Page title is derived from the slug: `managed-it` → "Managed It". +- Only direct children of `pages/` are processed (no subdirectories). + +### `theme/` directory +- Contains theme style/asset files (CSS, JS, JSON, PHP, HTML, SVG, TXT). +- Subdirectories are supported — e.g., `theme/assets/css/main.css` maps to `/assets/css/main.css`. +- Files are **not** applied automatically — they are fetched for preview. +- Admin can review each file, compare against the active theme, and selectively apply. +- Applied files are written directly into the active theme directory. + +## Supported Git Providers + +| Provider | Auth method | PAT format | +|---|---|---| +| **GitHub** (github.com + GHE) | `Bearer` token | Fine-grained PAT with `Contents: Read` | +| **GitLab** (gitlab.com + self-hosted) | `PRIVATE-TOKEN` header | Project/personal access token with `read_repository` | +| **Bitbucket Cloud** | Basic or Bearer | App password (`username:app_password`) or repository token | +| **Gitea / Forgejo** | `token` header | Application token with repo read access | +| **Azure DevOps** | Basic (`:PAT`) | Personal access token with `Code: Read` scope | + +Select your provider on the settings page, or leave it on "Auto-detect" to infer from the URL. + +## Setup + +1. Install and activate the plugin on your WordPress site. +2. Go to **Settings → Oribi Sync**. +3. Enter the **Repository URL** (HTTPS format, e.g., `https://github.com/owner/repo`, `https://gitlab.com/owner/repo`, `https://bitbucket.org/owner/repo`, `https://gitea.example.com/owner/repo`, or `https://dev.azure.com/org/project/_git/repo`). +4. Select the **Provider** (or leave on auto-detect). +5. Enter the **Branch** (defaults to `main`). +6. Enter a **Personal Access Token** with read access to the repository (see table above for format). +7. Click **Save Settings**. + +## Usage + +### Manual Sync +- Click **Sync Now** on the settings page to sync pages immediately. +- Click **Dry Run** to preview changes without modifying anything. +- Click **Preview Theme Files** to fetch and review theme files from the repo. + +### REST API + +All REST endpoints require `manage_options` capability (authenticated admin). + +```bash +# Trigger sync +curl -X POST https://yoursite.com/wp-json/oribi-sync/v1/sync \ + -H "X-WP-Nonce: " \ + --cookie "wordpress_logged_in_...=..." + +# Trigger dry-run +curl -X POST "https://yoursite.com/wp-json/oribi-sync/v1/sync?dry_run=1" \ + -H "X-WP-Nonce: " \ + --cookie "wordpress_logged_in_...=..." + +# Get status +curl https://yoursite.com/wp-json/oribi-sync/v1/status \ + -H "X-WP-Nonce: " \ + --cookie "wordpress_logged_in_...=..." +``` + +### Webhook + +Set up a webhook on your Git host to trigger syncs on push: + +**Endpoint:** `POST https://yoursite.com/wp-json/oribi-sync/v1/webhook` + +**Authentication** (one of): +- `Authorization: Bearer ` header +- GitHub `X-Hub-Signature-256` header (HMAC-SHA256) + +**Secret configuration** (one of): +- Define `ORIBI_SYNC_WEBHOOK_SECRET` in `wp-config.php` +- Store in WP option `oribi_sync_webhook_secret` + +## Security + +- PAT is encrypted with AES-256-CBC using a key derived from `AUTH_SALT`. +- All admin actions require `manage_options` capability and nonce verification. +- REST endpoints require authenticated admin user. +- Webhook endpoint validates shared secret or HMAC signature. +- Theme file writes are restricted to allowed extensions (CSS, JS, JSON, PHP, HTML, SVG, TXT). + +## Sync Behavior + +| Scenario | Action | +|---|---| +| New file in `pages/` | Create new WP page (published) | +| Changed file in `pages/` | Overwrite page content | +| Unchanged file in `pages/` | Skip (no unnecessary revisions) | +| File removed from `pages/` | Move corresponding WP page to Trash | +| New file in `theme/` | Available for preview & manual apply | +| Changed file in `theme/` | Available for preview & manual apply | + +## Requirements + +- WordPress 6.0+ +- PHP 7.4+ with `openssl` extension +- Git host with API access (GitHub or GitLab supported) diff --git a/assets/admin.css b/assets/admin.css new file mode 100644 index 0000000..3396c5c --- /dev/null +++ b/assets/admin.css @@ -0,0 +1,37 @@ +/** + * Oribi Sync — Admin styles + */ + +.oribi-sync-wrap .form-table th { + width: 200px; +} + +.oribi-sync-wrap .button { + margin-right: 6px; +} + +.oribi-sync-wrap hr { + margin: 24px 0; + border: none; + border-top: 1px solid #ddd; +} + +.oribi-sync-log td, +.oribi-sync-log th { + font-size: 13px; + padding: 6px 8px; +} + +.oribi-sync-wrap details summary { + cursor: pointer; + color: #2271b1; + font-size: 13px; +} + +.oribi-sync-wrap details summary:hover { + text-decoration: underline; +} + +.oribi-sync-wrap .description code { + font-size: 12px; +} diff --git a/dist/oribi-tech-sync.zip b/dist/oribi-tech-sync.zip new file mode 100644 index 0000000000000000000000000000000000000000..54d6f4485048106e1ee1bfe20b45a39a5e360e19 GIT binary patch literal 21650 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

MCVdMA%Py30<*~{02G1|w5!l<|fv89F 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 literal 0 HcmV?d00001 diff --git a/includes/admin.php b/includes/admin.php new file mode 100644 index 0000000..8a6e0f1 --- /dev/null +++ b/includes/admin.php @@ -0,0 +1,265 @@ + +

+

Oribi Sync

+ + +

Settings saved.

+ +

PAT has been cleared.

+ + + +
+

+ +

+
    + +
  • Created:
  • + + +
  • Updated:
  • + + +
  • Trashed:
  • + + +
  • Skipped:
  • + + +
  • Errors:
  • + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + +

Sync Actions

+

+ The plugin reads two folders from the repo: pages/ (PHP or HTML files → WP pages) + and theme/ (theme style files → preview & manual apply). Everything else is ignored.
+ PHP files are executed using the oribi_b() block helpers (requires Oribi Tech Setup plugin). + HTML files are used as raw Gutenberg block markup. +

+

+ + 🔄 Sync Now + + + + 🔍 Dry Run + + + + 🎨 Preview Theme Files + +

+ + +

Last sync:

+ + + + +
+

Theme Files Preview

+ + + + + +
+

Sync Log

+ + + + + + + + + + + + + + + + + + + + + +
TimeCreatedUpdatedTrashedSkippedErrors
+ +
+ '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; +} diff --git a/includes/crypto.php b/includes/crypto.php new file mode 100644 index 0000000..870fac8 --- /dev/null +++ b/includes/crypto.php @@ -0,0 +1,70 @@ + 'POST', + 'callback' => 'oribi_sync_rest_sync', + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + ] ); + + // ── Sync status ─────────────────────────────────────────────────────── + register_rest_route( 'oribi-sync/v1', '/status', [ + 'methods' => 'GET', + 'callback' => 'oribi_sync_rest_status', + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + ] ); + + // ── Webhook (secret-based auth, no WP login required) ───────────────── + register_rest_route( 'oribi-sync/v1', '/webhook', [ + 'methods' => 'POST', + 'callback' => 'oribi_sync_rest_webhook', + 'permission_callback' => '__return_true', // Auth handled in callback + ] ); +} ); + +/** + * REST: Trigger sync. + */ +function oribi_sync_rest_sync( WP_REST_Request $request ): WP_REST_Response { + $dry_run = (bool) $request->get_param( 'dry_run' ); + $result = oribi_sync_run( $dry_run ); + + return new WP_REST_Response( $result, $result['ok'] ? 200 : 500 ); +} + +/** + * REST: Get last sync status. + */ +function oribi_sync_rest_status(): WP_REST_Response { + return new WP_REST_Response( [ + 'last_run' => get_option( 'oribi_sync_last_run', null ), + 'log' => array_slice( get_option( 'oribi_sync_log', [] ), 0, 5 ), + 'repo' => get_option( 'oribi_sync_repo', '' ), + 'branch' => get_option( 'oribi_sync_branch', 'main' ), + 'provider' => oribi_sync_get_provider(), + 'has_pat' => ! empty( get_option( 'oribi_sync_pat', '' ) ), + ] ); +} + +/** + * REST: Webhook trigger. + * + * Validates using a shared secret stored in the WP option oribi_sync_webhook_secret + * or the constant ORIBI_SYNC_WEBHOOK_SECRET. + * + * Accepts GitHub-style X-Hub-Signature-256 header, or a simple + * Authorization: Bearer header. + */ +function oribi_sync_rest_webhook( WP_REST_Request $request ): WP_REST_Response { + $secret = defined( 'ORIBI_SYNC_WEBHOOK_SECRET' ) + ? ORIBI_SYNC_WEBHOOK_SECRET + : get_option( 'oribi_sync_webhook_secret', '' ); + + if ( empty( $secret ) ) { + return new WP_REST_Response( [ 'error' => 'Webhook secret not configured.' ], 403 ); + } + + // Check Authorization: Bearer + $auth = $request->get_header( 'Authorization' ); + if ( $auth && preg_match( '/^Bearer\s+(.+)$/i', $auth, $m ) ) { + if ( ! hash_equals( $secret, $m[1] ) ) { + return new WP_REST_Response( [ 'error' => 'Invalid secret.' ], 403 ); + } + } + // Check GitHub X-Hub-Signature-256 + elseif ( $sig = $request->get_header( 'X-Hub-Signature-256' ) ) { + $body = $request->get_body(); + $expected = 'sha256=' . hash_hmac( 'sha256', $body, $secret ); + if ( ! hash_equals( $expected, $sig ) ) { + return new WP_REST_Response( [ 'error' => 'Invalid signature.' ], 403 ); + } + } else { + return new WP_REST_Response( [ 'error' => 'Missing authentication.' ], 403 ); + } + + // Run sync + $result = oribi_sync_run(); + + return new WP_REST_Response( $result, $result['ok'] ? 200 : 500 ); +} diff --git a/includes/sync-engine.php b/includes/sync-engine.php new file mode 100644 index 0000000..035012e --- /dev/null +++ b/includes/sync-engine.php @@ -0,0 +1,417 @@ +getMessage() ); + } + + @unlink( $tmp ); + + // Prefer the return value; fall back to echoed output + if ( is_string( $returned ) && ! empty( trim( $returned ) ) ) { + return trim( $returned ); + } + if ( ! empty( trim( $echoed ) ) ) { + return trim( $echoed ); + } + + return new WP_Error( 'oribi_sync_empty_output', $slug . ': PHP file produced no output.' ); +} + +/** + * Run the full page sync. + * + * @param bool $dry_run If true, returns what would happen without making changes. + * + * @return array{ok: bool, created: string[], updated: string[], trashed: string[], skipped: string[], errors: string[]} + */ +function oribi_sync_run( bool $dry_run = false ): array { + $result = [ + 'ok' => true, + 'created' => [], + 'updated' => [], + 'trashed' => [], + 'skipped' => [], + 'errors' => [], + ]; + + // ── Gather settings ──────────────────────────────────────────────────── + $repo_url = get_option( 'oribi_sync_repo', '' ); + $branch = get_option( 'oribi_sync_branch', 'main' ) ?: 'main'; + $pat = oribi_sync_get_pat(); + + if ( empty( $repo_url ) || empty( $pat ) ) { + $result['ok'] = false; + $result['errors'][] = 'Repository URL or PAT is not configured.'; + return $result; + } + + // ── Parse repo URL ───────────────────────────────────────────────────── + $parsed = oribi_sync_parse_repo_url( $repo_url ); + if ( is_wp_error( $parsed ) ) { + $result['ok'] = false; + $result['errors'][] = $parsed->get_error_message(); + return $result; + } + + $provider = oribi_sync_get_provider(); + $api_base = oribi_sync_api_base( $provider, $parsed ); + + // ── Fetch tree ───────────────────────────────────────────────────────── + $tree = oribi_sync_fetch_tree( $api_base, $branch, $provider, $pat ); + if ( is_wp_error( $tree ) ) { + $result['ok'] = false; + $result['errors'][] = 'Tree fetch failed: ' . $tree->get_error_message(); + return $result; + } + + // ── Filter to pages/ directory ───────────────────────────────────────── + $page_files = oribi_sync_filter_tree( $tree, 'pages' ); + + if ( empty( $page_files ) ) { + $result['errors'][] = 'No files found under pages/ in the repository.'; + $result['ok'] = false; + return $result; + } + + // ── Process each page file ───────────────────────────────────────────── + $synced_slugs = []; + + foreach ( $page_files as $entry ) { + $filename = basename( $entry['path'] ); + $slug = oribi_sync_filename_to_slug( $filename ); + + if ( empty( $slug ) ) { + $result['skipped'][] = $entry['path'] . ' (could not derive slug)'; + continue; + } + + $synced_slugs[] = $slug; + + // Find existing page early so we can do change detection before fetching content + $existing = get_page_by_path( $slug ); + + // ── Fast-path change detection via git blob SHA ──────────────────── + // The tree API returns the blob SHA for GitHub, GitLab and Gitea. + // This SHA changes whenever the file content changes, so we can skip + // the (potentially cached) file-content fetch entirely when it matches. + $git_sha = $entry['sha'] ?? ''; + $stored_git_sha = $existing ? get_post_meta( $existing->ID, '_oribi_sync_git_sha', true ) : ''; + + if ( $existing && ! empty( $git_sha ) && $git_sha === $stored_git_sha ) { + $result['skipped'][] = $slug . ' (unchanged)'; + if ( ! $dry_run ) { + update_post_meta( $existing->ID, '_oribi_sync_last_run', current_time( 'mysql' ) ); + } + continue; + } + + // Fetch raw file from repo + $raw_content = oribi_sync_fetch_file( $api_base, $branch, $entry['path'], $provider, $pat ); + if ( is_wp_error( $raw_content ) ) { + $result['errors'][] = $entry['path'] . ': ' . $raw_content->get_error_message(); + continue; + } + + $raw_content = trim( $raw_content ); + + // Determine file type and resolve to block markup + $ext = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) ); + + if ( $ext === 'php' ) { + // Execute the PHP file to produce Gutenberg block markup + $content = oribi_sync_execute_php( $raw_content, $slug ); + if ( is_wp_error( $content ) ) { + $result['errors'][] = $entry['path'] . ': ' . $content->get_error_message(); + continue; + } + } else { + // HTML or other — use raw content directly as block markup + $content = $raw_content; + } + + // Checksum based on raw source — used as fallback for providers without tree SHA + $checksum = hash( 'sha256', $raw_content ); + + if ( $dry_run ) { + if ( $existing ) { + // git SHA already differs (or wasn't available) — report as updated + $old_checksum = get_post_meta( $existing->ID, '_oribi_sync_checksum', true ); + if ( empty( $git_sha ) && $old_checksum === $checksum ) { + $result['skipped'][] = $slug . ' (unchanged)'; + } else { + $result['updated'][] = $slug; + } + } else { + $result['created'][] = $slug; + } + continue; + } + + if ( $existing ) { + // For providers without a tree SHA, fall back to content checksum comparison + if ( empty( $git_sha ) ) { + $old_checksum = get_post_meta( $existing->ID, '_oribi_sync_checksum', true ); + if ( $old_checksum === $checksum ) { + $result['skipped'][] = $slug . ' (unchanged)'; + update_post_meta( $existing->ID, '_oribi_sync_last_run', current_time( 'mysql' ) ); + continue; + } + } + + $update_result = wp_update_post( [ + 'ID' => $existing->ID, + 'post_content' => $content, + 'post_status' => 'publish', + ], true ); + + if ( is_wp_error( $update_result ) ) { + $result['errors'][] = $slug . ': ' . $update_result->get_error_message(); + continue; + } + + update_post_meta( $existing->ID, '_oribi_sync_checksum', $checksum ); + update_post_meta( $existing->ID, '_oribi_sync_git_sha', $git_sha ); + update_post_meta( $existing->ID, '_oribi_sync_source', $repo_url . '@' . $branch . ':' . $entry['path'] ); + update_post_meta( $existing->ID, '_oribi_sync_last_run', current_time( 'mysql' ) ); + update_post_meta( $existing->ID, '_wp_page_template', 'default' ); + + $result['updated'][] = $slug; + } else { + // Create new page + $title = oribi_sync_slug_to_title( $slug ); + + $post_id = wp_insert_post( [ + 'post_title' => $title, + 'post_name' => $slug, + 'post_status' => 'publish', + 'post_type' => 'page', + 'post_content' => $content, + ], true ); + + if ( is_wp_error( $post_id ) ) { + $result['errors'][] = $slug . ': ' . $post_id->get_error_message(); + continue; + } + + update_post_meta( $post_id, '_oribi_sync_checksum', $checksum ); + update_post_meta( $post_id, '_oribi_sync_git_sha', $git_sha ); + update_post_meta( $post_id, '_oribi_sync_source', $repo_url . '@' . $branch . ':' . $entry['path'] ); + update_post_meta( $post_id, '_oribi_sync_last_run', current_time( 'mysql' ) ); + update_post_meta( $post_id, '_wp_page_template', 'default' ); + + $result['created'][] = $slug; + } + } + + // ── Trash pages removed from repo ────────────────────────────────────── + if ( ! $dry_run ) { + $trashed = oribi_sync_trash_removed_pages( $synced_slugs ); + $result['trashed'] = $trashed; + } + + // ── Record run ───────────────────────────────────────────────────────── + if ( ! $dry_run ) { + oribi_sync_record_run( $result ); + } + + return $result; +} + +/** + * Trash WP pages that were previously synced but are no longer in the repo. + * + * A page is considered "synced" if it has the _oribi_sync_checksum meta key. + * + * @param string[] $current_slugs Slugs found in the current repo tree. + * + * @return string[] Slugs of pages moved to trash. + */ +function oribi_sync_trash_removed_pages( array $current_slugs ): array { + $trashed = []; + + $query = new WP_Query( [ + 'post_type' => 'page', + 'post_status' => 'publish', + 'meta_key' => '_oribi_sync_checksum', + 'posts_per_page' => -1, + 'fields' => 'ids', + ] ); + + foreach ( $query->posts as $post_id ) { + $page = get_post( $post_id ); + if ( ! $page ) continue; + + $slug = $page->post_name; + + if ( ! in_array( $slug, $current_slugs, true ) ) { + wp_trash_post( $page->ID ); + $trashed[] = $slug; + } + } + + return $trashed; +} + +/** + * Convert a filename to a page slug. + * + * Examples: + * home.php → home + * managed-it.php → managed-it + * home.html → home + * My Page.html → my-page + * + * @return string Sanitized slug (empty on failure). + */ +function oribi_sync_filename_to_slug( string $filename ): string { + // Strip extension + $slug = pathinfo( $filename, PATHINFO_FILENAME ); + // Sanitize + $slug = sanitize_title( $slug ); + return $slug; +} + +/** + * Convert a slug to a human-readable page title. + * + * Examples: + * home → Home + * managed-it → Managed It + * 365care → 365care + */ +function oribi_sync_slug_to_title( string $slug ): string { + return ucwords( str_replace( '-', ' ', $slug ) ); +} + +/** + * Record a sync run in the options table. + */ +function oribi_sync_record_run( array $result ): void { + update_option( 'oribi_sync_last_run', current_time( 'mysql' ), 'no' ); + + $log = get_option( 'oribi_sync_log', [] ); + if ( ! is_array( $log ) ) $log = []; + + array_unshift( $log, [ + 'time' => current_time( 'mysql' ), + 'created' => $result['created'], + 'updated' => $result['updated'], + 'trashed' => $result['trashed'], + 'skipped' => $result['skipped'], + 'errors' => $result['errors'], + ] ); + + // Keep last 20 entries + $log = array_slice( $log, 0, 20 ); + update_option( 'oribi_sync_log', $log, 'no' ); +} + +/** + * Fetch theme files from the repo (for preview / apply). + * + * @return array{files: array, errors: string[]} + */ +function oribi_sync_fetch_theme_files(): array { + $out = [ 'files' => [], 'errors' => [] ]; + + $repo_url = get_option( 'oribi_sync_repo', '' ); + $branch = get_option( 'oribi_sync_branch', 'main' ) ?: 'main'; + $pat = oribi_sync_get_pat(); + + if ( empty( $repo_url ) || empty( $pat ) ) { + $out['errors'][] = 'Repository URL or PAT is not configured.'; + return $out; + } + + $parsed = oribi_sync_parse_repo_url( $repo_url ); + if ( is_wp_error( $parsed ) ) { + $out['errors'][] = $parsed->get_error_message(); + return $out; + } + + $provider = oribi_sync_get_provider(); + $api_base = oribi_sync_api_base( $provider, $parsed ); + + $tree = oribi_sync_fetch_tree( $api_base, $branch, $provider, $pat ); + if ( is_wp_error( $tree ) ) { + $out['errors'][] = 'Tree fetch failed: ' . $tree->get_error_message(); + return $out; + } + + $theme_entries = oribi_sync_filter_tree( $tree, 'theme', true ); + + foreach ( $theme_entries as $entry ) { + // Derive relative path by stripping the 'theme/' prefix + $relative = substr( $entry['path'], strlen( 'theme/' ) ); + $content = oribi_sync_fetch_file( $api_base, $branch, $entry['path'], $provider, $pat ); + + if ( is_wp_error( $content ) ) { + $out['errors'][] = $entry['path'] . ': ' . $content->get_error_message(); + continue; + } + + // Check if a matching file exists in the active theme + $theme_file = get_template_directory() . '/' . $relative; + $local_exists = file_exists( $theme_file ); + $local_content = $local_exists ? file_get_contents( $theme_file ) : null; + + $out['files'][] = [ + 'repo_path' => $entry['path'], + 'relative' => $relative, + 'content' => $content, + 'local_exists' => $local_exists, + 'local_content' => $local_content, + 'changed' => $local_exists ? ( $local_content !== $content ) : true, + ]; + } + + return $out; +} diff --git a/includes/theme-preview.php b/includes/theme-preview.php new file mode 100644 index 0000000..8a84179 --- /dev/null +++ b/includes/theme-preview.php @@ -0,0 +1,233 @@ + 'theme', + 'oribi_sync_saved' => 'no_files', + ], admin_url( 'options-general.php?page=oribi-sync' ) ) ); + exit; + } + + $theme_data = get_transient( 'oribi_sync_theme_preview' ); + if ( ! $theme_data || empty( $theme_data['files'] ) ) { + wp_redirect( add_query_arg( [ + 'oribi_sync_tab' => 'theme', + 'oribi_sync_saved' => 'expired', + ], admin_url( 'options-general.php?page=oribi-sync' ) ) ); + exit; + } + + $applied = []; + $errors = []; + $theme_dir = get_template_directory(); + + foreach ( $theme_data['files'] as $file ) { + $relative = $file['relative']; + + if ( ! in_array( $relative, $selected, true ) ) continue; + + $dest = $theme_dir . '/' . $relative; + + // Safety: only allow CSS, JS, JSON, PHP, HTML extensions + $ext = strtolower( pathinfo( $relative, PATHINFO_EXTENSION ) ); + $allowed = [ 'css', 'js', 'json', 'php', 'html', 'htm', 'svg', 'txt' ]; + if ( ! in_array( $ext, $allowed, true ) ) { + $errors[] = $relative . ' — file type not allowed.'; + continue; + } + + // Create subdirectory if needed + $dir = dirname( $dest ); + if ( ! is_dir( $dir ) ) { + if ( ! wp_mkdir_p( $dir ) ) { + $errors[] = $relative . ' — could not create directory.'; + continue; + } + } + + // Write file + $written = file_put_contents( $dest, $file['content'] ); + if ( $written === false ) { + $errors[] = $relative . ' — write failed (check permissions).'; + } else { + $applied[] = $relative; + } + } + + // Record + update_option( 'oribi_sync_theme_applied', [ + 'time' => current_time( 'mysql' ), + 'applied' => $applied, + 'errors' => $errors, + ], 'no' ); + + set_transient( 'oribi_sync_theme_result', [ + 'applied' => $applied, + 'errors' => $errors, + ], 60 ); + + wp_redirect( add_query_arg( [ + 'oribi_sync_tab' => 'theme', + 'oribi_sync_saved' => 'theme_applied', + ], admin_url( 'options-general.php?page=oribi-sync' ) ) ); + exit; +} ); + +// ─── Render theme preview panel (called from settings page) ────────────────── + +/** + * Render the theme preview panel within the settings page. + */ +function oribi_sync_render_theme_preview(): void { + $theme_data = get_transient( 'oribi_sync_theme_preview' ); + $theme_result = get_transient( 'oribi_sync_theme_result' ); + if ( $theme_result ) delete_transient( 'oribi_sync_theme_result' ); + + $saved = $_GET['oribi_sync_saved'] ?? ''; + ?> + + +
+

Theme files applied:

+ +
    + +
  • + +
+ + +
    + +
  • + +
+ +
+ +

No theme files were selected.

+ +

Theme preview data expired. Please preview again.

+ + + +

Click Preview Theme Files above to fetch files from the repo's theme/ directory.

+ + + + +
+

Errors fetching theme files:

+
    + +
  • + +
+
+ + + +

No files found under theme/ in the repository.

+ + + +
+ + + + + + + + + + + + + + + + + + + + +
FileStatusPreview
+ /> + + + New + + Changed + + Unchanged + + + +
+ View content +
+ +
+ Current local content +
+
+ +
+ + — + +
+ +

+ + + Files will be written directly into the active theme: + +

+
+ + + Settings'; + return $links; +} ); diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..b7c7cef --- /dev/null +++ b/uninstall.php @@ -0,0 +1,36 @@ + 'page', + 'post_status' => 'any', + 'posts_per_page' => -1, + 'meta_key' => '_oribi_sync_checksum', + 'fields' => 'ids', +] ); + +foreach ( $posts as $post_id ) { + delete_post_meta( $post_id, '_oribi_sync_checksum' ); + delete_post_meta( $post_id, '_oribi_sync_source' ); + delete_post_meta( $post_id, '_oribi_sync_last_run' ); +} + +// Clear any scheduled cron +wp_clear_scheduled_hook( 'oribi_sync_cron_run' );