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:
23
.vscode/tasks.json
vendored
Normal file
23
.vscode/tasks.json
vendored
Normal file
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
139
README.md
Normal file
139
README.md
Normal file
@@ -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 (`<!-- wp:... -->`) 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 `<active-theme>/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: <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: <nonce>" \
|
||||
--cookie "wordpress_logged_in_...=..."
|
||||
|
||||
# Get status
|
||||
curl https://yoursite.com/wp-json/oribi-sync/v1/status \
|
||||
-H "X-WP-Nonce: <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 <secret>` 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)
|
||||
37
assets/admin.css
Normal file
37
assets/admin.css
Normal file
@@ -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;
|
||||
}
|
||||
BIN
dist/oribi-tech-sync.zip
vendored
Normal file
BIN
dist/oribi-tech-sync.zip
vendored
Normal file
Binary file not shown.
265
includes/admin.php
Normal file
265
includes/admin.php
Normal file
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
/**
|
||||
* Oribi Sync — Admin settings page.
|
||||
*
|
||||
* Registers the Settings → Oribi Sync page, handles saving,
|
||||
* and provides the "Sync Now" action.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) exit;
|
||||
|
||||
// ─── Register admin menu ──────────────────────────────────────────────────────
|
||||
add_action( 'admin_menu', function () {
|
||||
add_options_page(
|
||||
'Oribi Sync',
|
||||
'Oribi Sync',
|
||||
'manage_options',
|
||||
'oribi-sync',
|
||||
'oribi_sync_settings_page'
|
||||
);
|
||||
} );
|
||||
|
||||
// ─── Enqueue admin CSS on our page only ───────────────────────────────────────
|
||||
add_action( 'admin_enqueue_scripts', function ( $hook ) {
|
||||
if ( $hook !== 'settings_page_oribi-sync' ) return;
|
||||
wp_enqueue_style( 'oribi-sync-admin', ORIBI_SYNC_URL . 'assets/admin.css', [], ORIBI_SYNC_VERSION );
|
||||
} );
|
||||
|
||||
// ─── Handle form submissions ──────────────────────────────────────────────────
|
||||
add_action( 'admin_post_oribi_sync_save_settings', function () {
|
||||
if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Permission denied.' );
|
||||
check_admin_referer( 'oribi_sync_save_settings' );
|
||||
|
||||
$repo = sanitize_text_field( wp_unslash( $_POST['oribi_sync_repo'] ?? '' ) );
|
||||
$branch = sanitize_text_field( wp_unslash( $_POST['oribi_sync_branch'] ?? 'main' ) );
|
||||
$provider = sanitize_text_field( wp_unslash( $_POST['oribi_sync_provider'] ?? '' ) );
|
||||
$pat = wp_unslash( $_POST['oribi_sync_pat'] ?? '' );
|
||||
|
||||
update_option( 'oribi_sync_repo', $repo, 'no' );
|
||||
update_option( 'oribi_sync_branch', $branch, 'no' );
|
||||
update_option( 'oribi_sync_provider', $provider, 'no' );
|
||||
|
||||
// Only update PAT if a new one was provided (non-empty)
|
||||
if ( ! empty( $pat ) ) {
|
||||
oribi_sync_save_pat( $pat );
|
||||
}
|
||||
|
||||
wp_redirect( add_query_arg( 'oribi_sync_saved', '1', admin_url( 'options-general.php?page=oribi-sync' ) ) );
|
||||
exit;
|
||||
} );
|
||||
|
||||
add_action( 'admin_post_oribi_sync_run', function () {
|
||||
if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Permission denied.' );
|
||||
check_admin_referer( 'oribi_sync_run' );
|
||||
|
||||
$result = oribi_sync_run();
|
||||
|
||||
set_transient( 'oribi_sync_result', $result, 60 );
|
||||
|
||||
wp_redirect( add_query_arg( 'oribi_sync_done', '1', admin_url( 'options-general.php?page=oribi-sync' ) ) );
|
||||
exit;
|
||||
} );
|
||||
|
||||
add_action( 'admin_post_oribi_sync_dry_run', function () {
|
||||
if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Permission denied.' );
|
||||
check_admin_referer( 'oribi_sync_dry_run' );
|
||||
|
||||
$result = oribi_sync_run( true );
|
||||
|
||||
set_transient( 'oribi_sync_result', $result, 60 );
|
||||
|
||||
wp_redirect( add_query_arg( 'oribi_sync_done', 'dry', admin_url( 'options-general.php?page=oribi-sync' ) ) );
|
||||
exit;
|
||||
} );
|
||||
|
||||
add_action( 'admin_post_oribi_sync_clear_pat', function () {
|
||||
if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Permission denied.' );
|
||||
check_admin_referer( 'oribi_sync_clear_pat' );
|
||||
|
||||
delete_option( 'oribi_sync_pat' );
|
||||
|
||||
wp_redirect( add_query_arg( 'oribi_sync_saved', 'pat_cleared', admin_url( 'options-general.php?page=oribi-sync' ) ) );
|
||||
exit;
|
||||
} );
|
||||
|
||||
// ─── Settings page renderer ──────────────────────────────────────────────────
|
||||
function oribi_sync_settings_page() {
|
||||
if ( ! current_user_can( 'manage_options' ) ) return;
|
||||
|
||||
$repo = get_option( 'oribi_sync_repo', '' );
|
||||
$branch = get_option( 'oribi_sync_branch', 'main' );
|
||||
$provider = get_option( 'oribi_sync_provider', '' );
|
||||
$has_pat = ! empty( get_option( 'oribi_sync_pat', '' ) );
|
||||
$last_run = get_option( 'oribi_sync_last_run', '' );
|
||||
$log = get_option( 'oribi_sync_log', [] );
|
||||
|
||||
// Transient result (after sync / dry-run)
|
||||
$sync_result = get_transient( 'oribi_sync_result' );
|
||||
if ( $sync_result ) delete_transient( 'oribi_sync_result' );
|
||||
|
||||
$saved = $_GET['oribi_sync_saved'] ?? '';
|
||||
$done = $_GET['oribi_sync_done'] ?? '';
|
||||
?>
|
||||
<div class="wrap oribi-sync-wrap">
|
||||
<h1>Oribi Sync</h1>
|
||||
|
||||
<?php if ( $saved === '1' ): ?>
|
||||
<div class="notice notice-success is-dismissible"><p>Settings saved.</p></div>
|
||||
<?php elseif ( $saved === 'pat_cleared' ): ?>
|
||||
<div class="notice notice-warning is-dismissible"><p>PAT has been cleared.</p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( $sync_result ): ?>
|
||||
<div class="notice <?php echo $sync_result['ok'] ? 'notice-success' : 'notice-error'; ?>">
|
||||
<p>
|
||||
<strong><?php echo $done === 'dry' ? '🔍 Dry-run results' : '✅ Sync complete'; ?></strong>
|
||||
</p>
|
||||
<ul style="list-style:disc; padding-left:1.5rem;">
|
||||
<?php if ( ! empty( $sync_result['created'] ) ): ?>
|
||||
<li>Created: <?php echo esc_html( implode( ', ', $sync_result['created'] ) ); ?></li>
|
||||
<?php endif; ?>
|
||||
<?php if ( ! empty( $sync_result['updated'] ) ): ?>
|
||||
<li>Updated: <?php echo esc_html( implode( ', ', $sync_result['updated'] ) ); ?></li>
|
||||
<?php endif; ?>
|
||||
<?php if ( ! empty( $sync_result['trashed'] ) ): ?>
|
||||
<li>Trashed: <?php echo esc_html( implode( ', ', $sync_result['trashed'] ) ); ?></li>
|
||||
<?php endif; ?>
|
||||
<?php if ( ! empty( $sync_result['skipped'] ) ): ?>
|
||||
<li>Skipped: <?php echo esc_html( implode( ', ', $sync_result['skipped'] ) ); ?></li>
|
||||
<?php endif; ?>
|
||||
<?php if ( ! empty( $sync_result['errors'] ) ): ?>
|
||||
<li style="color:#d63638;">Errors: <?php echo esc_html( implode( '; ', $sync_result['errors'] ) ); ?></li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Settings form -->
|
||||
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" class="oribi-sync-form">
|
||||
<input type="hidden" name="action" value="oribi_sync_save_settings" />
|
||||
<?php wp_nonce_field( 'oribi_sync_save_settings' ); ?>
|
||||
|
||||
<table class="form-table" role="presentation">
|
||||
<tr>
|
||||
<th scope="row"><label for="oribi_sync_repo">Repository URL</label></th>
|
||||
<td>
|
||||
<input type="url" name="oribi_sync_repo" id="oribi_sync_repo"
|
||||
value="<?php echo esc_attr( $repo ); ?>"
|
||||
class="regular-text" placeholder="https://github.com/owner/repo" />
|
||||
<p class="description">HTTPS URL to the Git repository (any provider).</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="oribi_sync_provider">Provider</label></th>
|
||||
<td>
|
||||
<select name="oribi_sync_provider" id="oribi_sync_provider" class="regular-text">
|
||||
<option value=""<?php selected( $provider, '' ); ?>>Auto-detect from URL</option>
|
||||
<?php foreach ( oribi_sync_providers() as $key => $label ): ?>
|
||||
<option value="<?php echo esc_attr( $key ); ?>"<?php selected( $provider, $key ); ?>>
|
||||
<?php echo esc_html( $label ); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="description">Select your Git hosting provider, or leave on auto-detect.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="oribi_sync_branch">Branch</label></th>
|
||||
<td>
|
||||
<input type="text" name="oribi_sync_branch" id="oribi_sync_branch"
|
||||
value="<?php echo esc_attr( $branch ); ?>"
|
||||
class="regular-text" placeholder="main" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="oribi_sync_pat">Personal Access Token</label></th>
|
||||
<td>
|
||||
<input type="password" name="oribi_sync_pat" id="oribi_sync_pat"
|
||||
value="" class="regular-text"
|
||||
placeholder="<?php echo $has_pat ? '•••••••• (saved — leave blank to keep)' : 'ghp_…'; ?>"
|
||||
autocomplete="off" />
|
||||
<p class="description">
|
||||
Read token for your repo. Stored encrypted in the database.
|
||||
<?php if ( $has_pat ): ?>
|
||||
|
||||
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oribi_sync_clear_pat' ), 'oribi_sync_clear_pat' ) ); ?>"
|
||||
onclick="return confirm('Clear the stored PAT?');"
|
||||
style="color:#d63638;">Clear PAT</a>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<?php submit_button( 'Save Settings' ); ?>
|
||||
</form>
|
||||
|
||||
<hr />
|
||||
|
||||
<!-- Sync actions -->
|
||||
<h2>Sync Actions</h2>
|
||||
<p class="description">
|
||||
The plugin reads two folders from the repo: <code>pages/</code> (PHP or HTML files → WP pages)
|
||||
and <code>theme/</code> (theme style files → preview & manual apply). Everything else is ignored.<br />
|
||||
PHP files are executed using the <code>oribi_b()</code> block helpers (requires Oribi Tech Setup plugin).
|
||||
HTML files are used as raw Gutenberg block markup.
|
||||
</p>
|
||||
<p>
|
||||
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oribi_sync_run' ), 'oribi_sync_run' ) ); ?>"
|
||||
class="button button-primary"
|
||||
onclick="return confirm('This will overwrite pages with content from the repository. Continue?');">
|
||||
🔄 Sync Now
|
||||
</a>
|
||||
|
||||
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oribi_sync_dry_run' ), 'oribi_sync_dry_run' ) ); ?>"
|
||||
class="button">
|
||||
🔍 Dry Run
|
||||
</a>
|
||||
|
||||
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oribi_sync_theme_preview' ), 'oribi_sync_theme_preview' ) ); ?>"
|
||||
class="button">
|
||||
🎨 Preview Theme Files
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<?php if ( $last_run ): ?>
|
||||
<p><strong>Last sync:</strong> <?php echo esc_html( $last_run ); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Theme files preview -->
|
||||
<?php if ( isset( $_GET['oribi_sync_tab'] ) && $_GET['oribi_sync_tab'] === 'theme' ): ?>
|
||||
<hr />
|
||||
<h2>Theme Files Preview</h2>
|
||||
<?php oribi_sync_render_theme_preview(); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Sync log -->
|
||||
<?php if ( ! empty( $log ) ): ?>
|
||||
<hr />
|
||||
<h2>Sync Log</h2>
|
||||
<table class="widefat fixed striped oribi-sync-log">
|
||||
<thead><tr>
|
||||
<th style="width:160px;">Time</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
<th>Trashed</th>
|
||||
<th>Skipped</th>
|
||||
<th>Errors</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ( array_slice( $log, 0, 10 ) as $entry ): ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html( $entry['time'] ?? '—' ); ?></td>
|
||||
<td><?php echo esc_html( implode( ', ', $entry['created'] ?? [] ) ?: '—' ); ?></td>
|
||||
<td><?php echo esc_html( implode( ', ', $entry['updated'] ?? [] ) ?: '—' ); ?></td>
|
||||
<td><?php echo esc_html( implode( ', ', $entry['trashed'] ?? [] ) ?: '—' ); ?></td>
|
||||
<td><?php echo esc_html( implode( ', ', $entry['skipped'] ?? [] ) ?: '—' ); ?></td>
|
||||
<td style="color:#d63638;"><?php echo esc_html( implode( '; ', $entry['errors'] ?? [] ) ?: '—' ); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
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;
|
||||
}
|
||||
70
includes/crypto.php
Normal file
70
includes/crypto.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
/**
|
||||
* Oribi Sync — Encryption helpers for PAT storage.
|
||||
*
|
||||
* Uses AES-256-CBC with a key derived from AUTH_SALT.
|
||||
* The stored value is base64( IV . ciphertext ).
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) exit;
|
||||
|
||||
/**
|
||||
* Derive a 32-byte encryption key from WP salts.
|
||||
*/
|
||||
function oribi_sync_encryption_key(): string {
|
||||
$salt = defined( 'AUTH_SALT' ) ? AUTH_SALT : 'oribi-sync-default-salt';
|
||||
return hash( 'sha256', $salt, true ); // 32 bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext string.
|
||||
*/
|
||||
function oribi_sync_encrypt( string $plaintext ): string {
|
||||
$method = 'aes-256-cbc';
|
||||
$key = oribi_sync_encryption_key();
|
||||
$iv = openssl_random_pseudo_bytes( openssl_cipher_iv_length( $method ) );
|
||||
$cipher = openssl_encrypt( $plaintext, $method, $key, OPENSSL_RAW_DATA, $iv );
|
||||
|
||||
if ( $cipher === false ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return base64_encode( $iv . $cipher );
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a stored value back to plaintext.
|
||||
*/
|
||||
function oribi_sync_decrypt( string $stored ): string {
|
||||
if ( empty( $stored ) ) return '';
|
||||
|
||||
$method = 'aes-256-cbc';
|
||||
$key = oribi_sync_encryption_key();
|
||||
$raw = base64_decode( $stored, true );
|
||||
if ( $raw === false ) return '';
|
||||
|
||||
$iv_len = openssl_cipher_iv_length( $method );
|
||||
if ( strlen( $raw ) < $iv_len ) return '';
|
||||
|
||||
$iv = substr( $raw, 0, $iv_len );
|
||||
$cipher = substr( $raw, $iv_len );
|
||||
|
||||
$result = openssl_decrypt( $cipher, $method, $key, OPENSSL_RAW_DATA, $iv );
|
||||
return $result !== false ? $result : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the PAT (encrypted, non-autoload).
|
||||
*/
|
||||
function oribi_sync_save_pat( string $plaintext_pat ): bool {
|
||||
$encrypted = oribi_sync_encrypt( $plaintext_pat );
|
||||
return update_option( 'oribi_sync_pat', $encrypted, 'no' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the decrypted PAT.
|
||||
*/
|
||||
function oribi_sync_get_pat(): string {
|
||||
$stored = get_option( 'oribi_sync_pat', '' );
|
||||
return oribi_sync_decrypt( $stored );
|
||||
}
|
||||
105
includes/rest.php
Normal file
105
includes/rest.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
/**
|
||||
* Oribi Sync — REST API endpoints.
|
||||
*
|
||||
* POST /wp-json/oribi-sync/v1/sync — Trigger a sync
|
||||
* POST /wp-json/oribi-sync/v1/sync — With ?dry_run=1 for preview
|
||||
* GET /wp-json/oribi-sync/v1/status — Get last sync status
|
||||
* POST /wp-json/oribi-sync/v1/webhook — Webhook trigger (secret-based auth)
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) exit;
|
||||
|
||||
add_action( 'rest_api_init', function () {
|
||||
|
||||
// ── Trigger sync ──────────────────────────────────────────────────────
|
||||
register_rest_route( 'oribi-sync/v1', '/sync', [
|
||||
'methods' => '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 <secret> 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 <secret>
|
||||
$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 );
|
||||
}
|
||||
417
includes/sync-engine.php
Normal file
417
includes/sync-engine.php
Normal file
@@ -0,0 +1,417 @@
|
||||
<?php
|
||||
/**
|
||||
* Oribi Sync — Sync engine.
|
||||
*
|
||||
* Fetches pages/ directory from the configured Git repository,
|
||||
* creates or updates WordPress pages, and trashes pages whose
|
||||
* source files have been removed from the repo.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) exit;
|
||||
|
||||
/**
|
||||
* Execute a PHP page-data file fetched from the repo and return its block markup.
|
||||
*
|
||||
* The file is expected to use oribi_b() / oribi_b_open() / oribi_b_close()
|
||||
* helpers (from oribi-tech-setup) and return a string of Gutenberg block markup.
|
||||
*
|
||||
* @param string $php_source Raw PHP source code from the repo.
|
||||
* @param string $slug Page slug (used for error context).
|
||||
*
|
||||
* @return string|WP_Error The rendered block markup, or WP_Error on failure.
|
||||
*/
|
||||
function oribi_sync_execute_php( string $php_source, string $slug ) {
|
||||
// Ensure the block helpers are available
|
||||
if ( ! function_exists( 'oribi_b' ) ) {
|
||||
return new WP_Error(
|
||||
'oribi_sync_missing_helpers',
|
||||
'Block helper functions (oribi_b, oribi_b_open, oribi_b_close) are not available. '
|
||||
. 'Make sure the Oribi Tech Setup plugin is active.'
|
||||
);
|
||||
}
|
||||
|
||||
// Write to a temp file so we can include it
|
||||
$tmp = wp_tempnam( 'oribi-sync-' . $slug . '.php' );
|
||||
if ( ! $tmp ) {
|
||||
return new WP_Error( 'oribi_sync_tmp_error', 'Could not create temporary file for ' . $slug );
|
||||
}
|
||||
|
||||
file_put_contents( $tmp, $php_source );
|
||||
|
||||
// Provide $c (contact page URL) — same convention as oribi_page_content()
|
||||
$c = '/contact';
|
||||
|
||||
// Capture the return value (page-data files use `return implode(…)`)
|
||||
try {
|
||||
ob_start();
|
||||
$returned = include $tmp;
|
||||
$echoed = ob_get_clean();
|
||||
} catch ( \Throwable $e ) {
|
||||
ob_end_clean();
|
||||
@unlink( $tmp );
|
||||
return new WP_Error( 'oribi_sync_php_error', $slug . ': PHP execution failed — ' . $e->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;
|
||||
}
|
||||
233
includes/theme-preview.php
Normal file
233
includes/theme-preview.php
Normal file
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
/**
|
||||
* Oribi Sync — Theme file preview & apply.
|
||||
*
|
||||
* Provides an admin screen that fetches files from the repo's `theme/`
|
||||
* directory, shows a preview diff against the active theme, and lets the
|
||||
* admin selectively apply files.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) exit;
|
||||
|
||||
// ─── Handle theme preview request ────────────────────────────────────────────
|
||||
add_action( 'admin_post_oribi_sync_theme_preview', function () {
|
||||
if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Permission denied.' );
|
||||
check_admin_referer( 'oribi_sync_theme_preview' );
|
||||
|
||||
$theme_data = oribi_sync_fetch_theme_files();
|
||||
set_transient( 'oribi_sync_theme_preview', $theme_data, 300 );
|
||||
|
||||
wp_redirect( add_query_arg( 'oribi_sync_tab', 'theme', admin_url( 'options-general.php?page=oribi-sync' ) ) );
|
||||
exit;
|
||||
} );
|
||||
|
||||
// ─── Handle theme file apply ─────────────────────────────────────────────────
|
||||
add_action( 'admin_post_oribi_sync_theme_apply', function () {
|
||||
if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Permission denied.' );
|
||||
check_admin_referer( 'oribi_sync_theme_apply' );
|
||||
|
||||
$selected = $_POST['oribi_sync_theme_files'] ?? [];
|
||||
if ( ! is_array( $selected ) || empty( $selected ) ) {
|
||||
wp_redirect( add_query_arg( [
|
||||
'oribi_sync_tab' => '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'] ?? '';
|
||||
?>
|
||||
|
||||
<?php if ( $saved === 'theme_applied' && $theme_result ): ?>
|
||||
<div class="notice notice-success">
|
||||
<p><strong>Theme files applied:</strong></p>
|
||||
<?php if ( ! empty( $theme_result['applied'] ) ): ?>
|
||||
<ul style="list-style:disc;padding-left:1.5rem;">
|
||||
<?php foreach ( $theme_result['applied'] as $f ): ?>
|
||||
<li><?php echo esc_html( $f ); ?> ✓</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
<?php if ( ! empty( $theme_result['errors'] ) ): ?>
|
||||
<ul style="list-style:disc;padding-left:1.5rem;color:#d63638;">
|
||||
<?php foreach ( $theme_result['errors'] as $e ): ?>
|
||||
<li><?php echo esc_html( $e ); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php elseif ( $saved === 'no_files' ): ?>
|
||||
<div class="notice notice-warning is-dismissible"><p>No theme files were selected.</p></div>
|
||||
<?php elseif ( $saved === 'expired' ): ?>
|
||||
<div class="notice notice-warning is-dismissible"><p>Theme preview data expired. Please preview again.</p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( ! $theme_data ): ?>
|
||||
<p>Click <strong>Preview Theme Files</strong> above to fetch files from the repo's <code>theme/</code> directory.</p>
|
||||
<?php return; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( ! empty( $theme_data['errors'] ) ): ?>
|
||||
<div class="notice notice-error">
|
||||
<p><strong>Errors fetching theme files:</strong></p>
|
||||
<ul style="list-style:disc;padding-left:1.5rem;">
|
||||
<?php foreach ( $theme_data['errors'] as $err ): ?>
|
||||
<li><?php echo esc_html( $err ); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( empty( $theme_data['files'] ) ): ?>
|
||||
<p>No files found under <code>theme/</code> in the repository.</p>
|
||||
<?php return; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
|
||||
<input type="hidden" name="action" value="oribi_sync_theme_apply" />
|
||||
<?php wp_nonce_field( 'oribi_sync_theme_apply' ); ?>
|
||||
|
||||
<table class="widefat fixed striped">
|
||||
<thead><tr>
|
||||
<th style="width:30px;"><input type="checkbox" id="oribi-sync-check-all" /></th>
|
||||
<th>File</th>
|
||||
<th style="width:100px;">Status</th>
|
||||
<th>Preview</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ( $theme_data['files'] as $file ): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" name="oribi_sync_theme_files[]"
|
||||
value="<?php echo esc_attr( $file['relative'] ); ?>"
|
||||
<?php echo $file['changed'] ? '' : 'disabled'; ?> />
|
||||
</td>
|
||||
<td><code><?php echo esc_html( $file['relative'] ); ?></code></td>
|
||||
<td>
|
||||
<?php if ( ! $file['local_exists'] ): ?>
|
||||
<span style="color:#00a32a;font-weight:bold;">New</span>
|
||||
<?php elseif ( $file['changed'] ): ?>
|
||||
<span style="color:#dba617;font-weight:bold;">Changed</span>
|
||||
<?php else: ?>
|
||||
<span style="color:#999;">Unchanged</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ( $file['changed'] ): ?>
|
||||
<details>
|
||||
<summary>View content</summary>
|
||||
<pre style="max-height:300px;overflow:auto;background:#f6f7f7;padding:8px;font-size:12px;border:1px solid #ddd;"><?php echo esc_html( $file['content'] ); ?></pre>
|
||||
<?php if ( $file['local_exists'] && $file['local_content'] !== null ): ?>
|
||||
<details style="margin-top:4px;">
|
||||
<summary>Current local content</summary>
|
||||
<pre style="max-height:300px;overflow:auto;background:#fef7f1;padding:8px;font-size:12px;border:1px solid #ddd;"><?php echo esc_html( $file['local_content'] ); ?></pre>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
</details>
|
||||
<?php else: ?>
|
||||
—
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:12px;">
|
||||
<button type="submit" class="button button-primary"
|
||||
onclick="return confirm('Apply selected files to the active theme directory?');">
|
||||
Apply Selected Files
|
||||
</button>
|
||||
<span class="description" style="margin-left:8px;">
|
||||
Files will be written directly into the active theme: <code><?php echo esc_html( basename( get_template_directory() ) ); ?></code>
|
||||
</span>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.getElementById('oribi-sync-check-all')?.addEventListener('change', function() {
|
||||
document.querySelectorAll('input[name="oribi_sync_theme_files[]"]:not(:disabled)')
|
||||
.forEach(cb => cb.checked = this.checked);
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
|
||||
// ─── Hook the theme preview into the settings page ───────────────────────────
|
||||
// We append it via a late-bound action so the admin page can call it.
|
||||
// The settings page checks for the 'oribi_sync_tab' parameter and renders this.
|
||||
51
oribi-tech-sync.php
Normal file
51
oribi-tech-sync.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: Oribi Tech Sync
|
||||
* Description: Sync WordPress pages and theme files from a remote Git repository. Configure a repo URL + PAT, then sync on command via admin UI or REST API.
|
||||
* Version: 1.0.0
|
||||
* Author: Oribi Technology Services
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 7.4
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) exit;
|
||||
|
||||
define( 'ORIBI_SYNC_VERSION', '1.0.0' );
|
||||
define( 'ORIBI_SYNC_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'ORIBI_SYNC_URL', plugin_dir_url( __FILE__ ) );
|
||||
define( 'ORIBI_SYNC_BASENAME', plugin_basename( __FILE__ ) );
|
||||
|
||||
// ─── Includes ─────────────────────────────────────────────────────────────────
|
||||
require_once ORIBI_SYNC_DIR . 'includes/crypto.php';
|
||||
require_once ORIBI_SYNC_DIR . 'includes/api-client.php';
|
||||
require_once ORIBI_SYNC_DIR . 'includes/sync-engine.php';
|
||||
require_once ORIBI_SYNC_DIR . 'includes/admin.php';
|
||||
require_once ORIBI_SYNC_DIR . 'includes/rest.php';
|
||||
require_once ORIBI_SYNC_DIR . 'includes/theme-preview.php';
|
||||
|
||||
// ─── Activation / Deactivation ────────────────────────────────────────────────
|
||||
register_activation_hook( __FILE__, 'oribi_sync_activate' );
|
||||
register_deactivation_hook( __FILE__, 'oribi_sync_deactivate' );
|
||||
|
||||
function oribi_sync_activate() {
|
||||
// Ensure default options exist
|
||||
add_option( 'oribi_sync_repo', '', '', 'no' );
|
||||
add_option( 'oribi_sync_branch', 'main', '', 'no' );
|
||||
add_option( 'oribi_sync_provider', '', '', 'no' );
|
||||
add_option( 'oribi_sync_pat', '', '', 'no' );
|
||||
add_option( 'oribi_sync_last_run', '', '', 'no' );
|
||||
add_option( 'oribi_sync_log', [], '', 'no' );
|
||||
}
|
||||
|
||||
function oribi_sync_deactivate() {
|
||||
wp_clear_scheduled_hook( 'oribi_sync_cron_run' );
|
||||
}
|
||||
|
||||
// ─── Uninstall (static, called by WP) ─────────────────────────────────────────
|
||||
// See uninstall.php for full cleanup.
|
||||
|
||||
// ─── Plugin action links ──────────────────────────────────────────────────────
|
||||
add_filter( 'plugin_action_links_' . ORIBI_SYNC_BASENAME, function ( $links ) {
|
||||
$links[] = '<a href="' . esc_url( admin_url( 'options-general.php?page=oribi-sync' ) ) . '">Settings</a>';
|
||||
return $links;
|
||||
} );
|
||||
36
uninstall.php
Normal file
36
uninstall.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
/**
|
||||
* Oribi Sync — Uninstall routine.
|
||||
*
|
||||
* Called by WordPress when the plugin is deleted via the admin UI.
|
||||
* Removes all plugin options from the database.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) exit;
|
||||
|
||||
delete_option( 'oribi_sync_repo' );
|
||||
delete_option( 'oribi_sync_branch' );
|
||||
delete_option( 'oribi_sync_provider' );
|
||||
delete_option( 'oribi_sync_pat' );
|
||||
delete_option( 'oribi_sync_last_run' );
|
||||
delete_option( 'oribi_sync_log' );
|
||||
delete_option( 'oribi_sync_webhook_secret' );
|
||||
delete_option( 'oribi_sync_theme_applied' );
|
||||
|
||||
// Remove sync metadata from posts
|
||||
$posts = get_posts( [
|
||||
'post_type' => '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' );
|
||||
Reference in New Issue
Block a user