oauth = new Github_Oauth(); $this->api = new Github_Api_Client( $this->oauth ); $this->store = new Project_Store(); } /** * Wire up the webhook receiver and the cron safety net. Called * unconditionally from Plugin's constructor (unlike the admin * page, this has to run on every request, including the * unauthenticated one GitHub itself makes to deliver a webhook). */ public function register() { add_action( 'rest_api_init', array( $this, 'register_rest_route' ) ); add_action( self::CRON_HOOK, array( $this, 'check_for_missed_runs' ) ); if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', self::CRON_HOOK ); } } public function register_rest_route() { register_rest_route( self::REST_NAMESPACE, self::REST_ROUTE, array( 'methods' => 'POST', 'callback' => array( $this, 'handle_webhook_request' ), // GitHub can't authenticate as a WP user — the request // itself is verified by its HMAC signature instead, // inside the callback, against each project's own // per-project secret. See verify_signature(). 'permission_callback' => '__return_true', ) ); } /** * @return string This site's webhook receiver URL, for * Github_Api_Client::create_webhook(). */ public function webhook_url() { return rest_url( self::REST_NAMESPACE . self::REST_ROUTE ); } /** * Connect a GitHub repo: make sure the build workflow is committed * to it, register a webhook, and save the project record. Does * NOT run a conversion itself — call sync_project() right after if * an immediate first build/convert is wanted (the admin "Add * project" handler does exactly that). * * @param string $owner GitHub owner/org. * @param string $repo Repo name. * @param string $branch Branch to track. * @param string $theme_slug Preferred theme slug. * @param bool $activate_on_first_sync Activate once the first * sync succeeds. * @param bool $recover_images Passed through to Converter * on every sync. * * @return Connected_Project|\WP_Error */ public function connect_project( $owner, $repo, $branch, $theme_slug, $activate_on_first_sync, $recover_images ) { if ( ! $this->oauth->is_connected() ) { return new \WP_Error( 'esipher_github_not_connected', __( 'Connect your GitHub account first.', 'esipher' ) ); } $repo_info = $this->api->get_repo( $owner, $repo ); if ( is_wp_error( $repo_info ) ) { return $repo_info; } if ( '' === $branch ) { $branch = ! empty( $repo_info['default_branch'] ) ? $repo_info['default_branch'] : 'main'; } $workflow_template = ESIPHER_PLUGIN_DIR . 'templates/github-workflow.yml.tpl'; $workflow_yaml = is_file( $workflow_template ) ? (string) file_get_contents( $workflow_template ) : ''; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading this plugin's own bundled template file, not user input. if ( '' === $workflow_yaml ) { return new \WP_Error( 'esipher_github_template_missing', __( 'Esipher\'s bundled build workflow template is missing — reinstall the plugin.', 'esipher' ) ); } $ensured = $this->api->ensure_workflow_file( $owner, $repo, $branch, $workflow_yaml ); if ( is_wp_error( $ensured ) ) { return $ensured; } $existing = $this->store->find_by_repo( $owner, $repo ); $project = $existing ? $existing : new Connected_Project(); // Re-connecting an already-connected repo (e.g. to change the // tracked branch) reuses its existing webhook rather than // creating a second one pointed at the same URL. if ( empty( $project->webhook_id ) ) { $secret = wp_generate_password( 40, false ); $webhook_id = $this->api->create_webhook( $owner, $repo, $this->webhook_url(), $secret ); if ( is_wp_error( $webhook_id ) ) { return $webhook_id; } $project->webhook_id = $webhook_id; $project->webhook_secret = $secret; } $project->owner = $owner; $project->repo = $repo; $project->branch = $branch; $project->theme_slug = sanitize_title( $theme_slug ); $project->activate_on_first_sync = (bool) $activate_on_first_sync; $project->recover_images = (bool) $recover_images; $project->status = $project->status ?: 'connected'; $this->store->save( $project ); return $project; } /** * Remove a project's webhook from GitHub and forget it locally. * Never touches the already-installed WordPress theme or its * pages — disconnecting stops future auto-syncs, it isn't an * uninstall. * * @param string $id Project ID. * * @return true|\WP_Error */ public function disconnect_project( $id ) { $project = $this->store->get( $id ); if ( ! $project ) { return new \WP_Error( 'esipher_github_unknown_project', __( 'Unknown project.', 'esipher' ) ); } $webhook_warning = ''; if ( ! empty( $project->webhook_id ) && $this->oauth->is_connected() ) { $deleted = $this->api->delete_webhook( $project->owner, $project->repo, $project->webhook_id ); // A failure to delete the hook on GitHub's side (already // gone, permissions changed, transient API error) must not // strand the local record — that's what left a "reconnect" // silently reusing stale sync state. The leftover hook just // logs failed deliveries against a URL that now 404s, the // same harmless end state delete_webhook() already accepts // for a 404. Report it, but still forget the project. if ( is_wp_error( $deleted ) ) { $webhook_warning = $deleted->get_error_message(); } } $this->store->delete( $id ); if ( '' !== $webhook_warning ) { return new \WP_Error( 'esipher_github_webhook_delete_failed', sprintf( /* translators: %s: underlying GitHub API error message. */ __( 'Project disconnected. GitHub\'s webhook could not be removed automatically (%s) — delete it by hand under the repo\'s Settings → Webhooks if you want it gone.', 'esipher' ), $webhook_warning ) ); } return true; } /** * Manually trigger a build+convert right now, without waiting for * the next push — the admin "Sync now" button. Only * dispatches the workflow; the resulting build's completion still * arrives (and is converted) via the normal webhook path, same as * any other push. If a response is wanted before that webhook * fires, check_for_missed_runs()'s own polling loop is a better * fit than blocking this request on it. * * @param string $id Project ID. * * @return true|\WP_Error */ public function trigger_manual_sync( $id ) { $project = $this->store->get( $id ); if ( ! $project ) { return new \WP_Error( 'esipher_github_unknown_project', __( 'Unknown project.', 'esipher' ) ); } return $this->api->dispatch_workflow( $project->owner, $project->repo, $project->branch ); } /** * The REST callback for GitHub's webhook_run webhook deliveries. * * @param \WP_REST_Request $request * * @return \WP_REST_Response */ public function handle_webhook_request( \WP_REST_Request $request ) { $payload = json_decode( $request->get_body(), true ); if ( ! is_array( $payload ) || empty( $payload['repository']['owner']['login'] ) || empty( $payload['repository']['name'] ) ) { return new \WP_REST_Response( array( 'ok' => false, 'error' => 'malformed payload' ), 400 ); } $owner = $payload['repository']['owner']['login']; $repo = $payload['repository']['name']; $project = $this->store->find_by_repo( $owner, $repo ); if ( ! $project ) { // Not one of ours (or already disconnected) — 404 rather // than 200, so it shows up as a real delivery failure in // GitHub's own webhook log instead of looking successful. return new \WP_REST_Response( array( 'ok' => false, 'error' => 'unknown project' ), 404 ); } if ( ! $this->verify_signature( $request, $project->webhook_secret ) ) { return new \WP_REST_Response( array( 'ok' => false, 'error' => 'signature mismatch' ), 401 ); } // GitHub sends workflow_run deliveries for 'requested', // 'in_progress', and 'completed' — only the last one has an // artifact ready to fetch. $action = $payload['action'] ?? ''; if ( 'completed' !== $action ) { return new \WP_REST_Response( array( 'ok' => true, 'skipped' => 'not completed yet' ), 200 ); } $run = $payload['workflow_run'] ?? array(); if ( 'success' !== ( $run['conclusion'] ?? '' ) ) { $project->status = 'error'; $project->last_error = sprintf( /* translators: %s: GitHub Actions run conclusion, e.g. "failure". */ __( 'The build on GitHub finished with conclusion "%s" — open the Actions tab on the repo to see why.', 'esipher' ), (string) ( $run['conclusion'] ?? 'unknown' ) ); $this->store->save( $project ); return new \WP_REST_Response( array( 'ok' => true, 'recorded' => 'build failure' ), 200 ); } if ( ( $run['head_branch'] ?? '' ) !== $project->branch ) { return new \WP_REST_Response( array( 'ok' => true, 'skipped' => 'different branch' ), 200 ); } $sha = (string) ( $run['head_sha'] ?? '' ); if ( '' !== $sha && $sha === $project->last_synced_sha ) { return new \WP_REST_Response( array( 'ok' => true, 'skipped' => 'already synced' ), 200 ); } // Record that a build is ready; never act on it here. A webhook // request is unauthenticated apart from its signature, so it is // only allowed to flag "update available" — installing or // activating a theme requires a logged-in administrator (see // apply_pending_update()). $this->record_pending_update( $project, (int) $run['id'], $sha ); return new \WP_REST_Response( array( 'ok' => true, 'recorded' => 'update ready' ), 200 ); } /** * Hourly cron safety net: for every connected project, check * whether the latest completed run on its tracked branch is newer * than what was last synced — catches the rare case a webhook * delivery never arrived. Only records the "update ready" flag, the * same as the webhook does; the theme is never touched from here. */ public function check_for_missed_runs() { if ( ! $this->oauth->is_connected() ) { return; } foreach ( $this->store->all() as $project ) { $run = $this->api->get_latest_run( $project->owner, $project->repo, $project->branch ); if ( is_wp_error( $run ) ) { continue; // No runs yet, or a transient API error — nothing actionable. } if ( 'completed' !== ( $run['status'] ?? '' ) || 'success' !== ( $run['conclusion'] ?? '' ) ) { continue; } $sha = (string) ( $run['head_sha'] ?? '' ); if ( '' === $sha || $sha === $project->last_synced_sha ) { continue; } $this->record_pending_update( $project, (int) $run['id'], $sha ); } } /** * Flag a finished, successful build as waiting to be applied. * * @param Connected_Project $project * @param int $run_id * @param string $sha */ private function record_pending_update( Connected_Project $project, $run_id, $sha ) { if ( '' !== $sha && $sha === $project->pending_sha && (int) $run_id === (int) $project->pending_run_id ) { return; // Already recorded (webhook + cron saw the same run). } $project->pending_run_id = (int) $run_id; $project->pending_sha = $sha; $project->status = 'update_ready'; $project->last_error = ''; $this->store->save( $project ); } /** * Apply the build waiting for $id: download its artifact and run it * through the same Converter/Installer pipeline the manual upload * form uses. Only ever called from an authenticated admin action * (nonce + capability checked by the caller) or WP-CLI. * * @param string $id Project ID. * * @return true|\WP_Error */ public function apply_pending_update( $id ) { $project = $this->store->get( $id ); if ( ! $project ) { return new \WP_Error( 'esipher_github_unknown_project', __( 'Unknown project.', 'esipher' ) ); } if ( ! $this->oauth->is_connected() ) { return new \WP_Error( 'esipher_github_not_connected', __( 'Connect your GitHub account first.', 'esipher' ) ); } if ( $project->pending_run_id <= 0 ) { return new \WP_Error( 'esipher_github_nothing_pending', __( 'There is no new build waiting to be applied.', 'esipher' ) ); } return $this->convert_from_run( $project, (int) $project->pending_run_id, (string) $project->pending_sha ); } /** * Download the given run's artifact and run it through the same * Converter/Installer pipeline the manual upload form uses. * * @param Connected_Project $project * @param int $run_id * @param string $sha * * @return true|\WP_Error */ private function convert_from_run( Connected_Project $project, $run_id, $sha ) { $artifact = $this->api->find_build_artifact( $project->owner, $project->repo, $run_id ); if ( is_wp_error( $artifact ) ) { return $this->record_failure( $project, $artifact ); } $zip_path = $this->api->download_artifact( $project->owner, $project->repo, $artifact['id'] ); if ( is_wp_error( $zip_path ) ) { return $this->record_failure( $project, $zip_path ); } // A repeat sync reuses the same slug on purpose (see // Installer::delete_theme_directory_if_exists()'s own // docblock) — clear any previous version of it first so the // write below is a clean "fail unless the slug is genuinely // free" rather than needing on_collision=suffix, which would // pile up a new theme copy (and duplicate pages) on every push. $already_synced_once = '' !== $project->last_synced_sha; // Activate the theme whenever this project is set to AND it // isn't already the active one — not just on the very first // sync. The old "first sync only" rule meant that once anything // changed the active theme (a user previewing another theme, or // deleting/reinstalling during setup), every later sync would // keep reporting "synced" while the site still showed the old // theme, with nothing to explain the mismatch. With the box // checked (the default) a connected project now reliably means // "keep my site on the latest build". $should_activate = $project->activate_on_first_sync && get_stylesheet() !== $project->theme_slug; try { if ( $already_synced_once ) { ( new Installer() )->delete_theme_directory_if_exists( $project->theme_slug ); } } catch ( Installer_Exception $e ) { if ( file_exists( $zip_path ) ) { wp_delete_file( $zip_path ); } return $this->record_failure( $project, new \WP_Error( 'esipher_github_cleanup_failed', $e->getMessage() ) ); } $result = ( new Converter() )->convert( $zip_path, array( 'preferred_slug' => $project->theme_slug, 'activate' => $should_activate, 'on_collision' => 'fail', 'recover_images' => $project->recover_images, ) ); if ( file_exists( $zip_path ) ) { wp_delete_file( $zip_path ); } if ( ! $result->success ) { return $this->record_failure( $project, new \WP_Error( 'esipher_github_convert_failed', (string) $result->error_message ) ); } $project->status = 'synced'; $project->pending_run_id = 0; $project->pending_sha = ''; $project->last_synced_sha = $sha; $project->last_synced_at = current_time( 'mysql' ); $project->theme_slug = $result->slug; // In case resolve_slug ever had to adjust it on a truly first install. // The conversion succeeded, but if this project's theme still // isn't the active one (activation is switched off for it, or // switch_theme() was blocked), say so plainly rather than // leaving a bare "Up to date" that doesn't match the site. $project->last_error = ( ! $result->activated && get_stylesheet() !== $project->theme_slug ) ? sprintf( /* translators: %s: theme slug. */ __( 'Synced, but theme "%s" is not active — activate it under Appearance → Themes to see the changes.', 'esipher' ), $project->theme_slug ) : ''; $this->store->save( $project ); return true; } /** * @param Connected_Project $project * @param \WP_Error $error * * @return \WP_Error The same error, passed through for the caller's * own return value. */ private function record_failure( Connected_Project $project, \WP_Error $error ) { $project->status = 'error'; $project->last_error = $error->get_error_message(); $this->store->save( $project ); return $error; } /** * Verify GitHub's X-Hub-Signature-256 header against this * project's own webhook secret — the only thing standing between * this REST route (necessarily open to unauthenticated requests, * since GitHub is the caller) and anyone on the internet being * able to trigger a "reconvert this project" for a repo they don't * own. * * @param \WP_REST_Request $request * @param string $secret * * @return bool */ private function verify_signature( \WP_REST_Request $request, $secret ) { if ( '' === $secret ) { return false; } $header = $request->get_header( 'x_hub_signature_256' ); if ( ! $header || 0 !== strpos( $header, 'sha256=' ) ) { return false; } $expected = 'sha256=' . hash_hmac( 'sha256', $request->get_body(), $secret ); return hash_equals( $expected, $header ); } }