<?php
/**
 * LinkQuiver self-update.
 *
 * Exposes POST /linkquiver/v1/self-update so the LinkQuiver platform can push a
 * new plugin build over REST — no SSH, no manual zip upload. The route downloads
 * a PINNED, SIGNED zip, verifies its detached RSA-SHA256 signature against the
 * public key embedded below, and only then overwrite-installs from the local,
 * verified file.
 *
 * Security model (defence in depth):
 *  - The source URL is HARD-PINNED in code (never read from the request), so an
 *    authenticated caller cannot point self-update at an arbitrary zip. No SSRF,
 *    no arbitrary-code install.
 *  - Every release zip is signed with our RSA-2048 PRIVATE key (held only in CI /
 *    .env.local, never in the running site). This route verifies against the
 *    embedded PUBLIC key BEFORE install. Even a fully compromised R2 bucket /
 *    origin can't push code we didn't sign — fail-closed on any mismatch.
 *  - Auth = a valid LinkQuiver API key OR a logged-in admin able to install
 *    plugins (Application Password). The key can therefore only ever trigger a
 *    reinstall of OUR current signed build, which is idempotent and harmless.
 *
 * @package Linkquiver
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

class Linkquiver_Self_Update {

    /**
     * Pinned zip + detached signature. Served by the LinkQuiver app, which
     * streams the exact signed bytes the release script uploaded to R2 — so the
     * signature always matches what this route downloads. Domain is one WE
     * control; it is never taken from the request. `linkquiver_self_update_source`
     * / `_sig` filters exist ONLY to point a local dev install at a test build.
     */
    const SOURCE_ZIP = 'https://linkquiver.com/api/wordpress/linkquiver/self-update/zip';
    const SOURCE_SIG = 'https://linkquiver.com/api/wordpress/linkquiver/self-update/sig';

    public static function source_zip() {
        return (string) apply_filters( 'linkquiver_self_update_source', self::SOURCE_ZIP );
    }

    public static function source_sig() {
        return (string) apply_filters( 'linkquiver_self_update_sig', self::SOURCE_SIG );
    }

    /**
     * Fetch the detached signature that goes with the pinned zip.
     * Shared with Linkquiver_Updater, which verifies the same artifact when the
     * update comes down WordPress's own update flow instead of our REST push.
     *
     * @return string|WP_Error Base64 signature body.
     */
    public static function fetch_signature() {
        $resp = wp_remote_get( self::source_sig(), array(
            'timeout'    => 30,
            'sslverify'  => true,
            // Neutral UA: WordPress would otherwise append this site's own URL.
            'user-agent' => 'LinkQuiver-Plugin/' . LINKQUIVER_VERSION,
        ) );
        if ( is_wp_error( $resp ) ) {
            return $resp;
        }
        if ( 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
            return new WP_Error( 'lq_sig_http', 'signature fetch failed: HTTP ' . wp_remote_retrieve_response_code( $resp ) );
        }
        return (string) wp_remote_retrieve_body( $resp );
    }

    /**
     * Auth: a logged-in admin who can install plugins (Application Password over
     * REST satisfies this) OR a valid LinkQuiver API key. Mirrors the plugin's
     * existing theme_permission pattern. Because the install source is pinned and
     * signature-verified, neither credential can install anything but our build.
     */
    public function permission( WP_REST_Request $request ) {
        if ( current_user_can( 'update_plugins' ) && current_user_can( 'install_plugins' ) ) {
            return true;
        }
        return Linkquiver_API_Key::validate( $request );
    }

    /**
     * Our release-signing PUBLIC key (RSA-2048). Long-lived: rotating it would
     * break self-update on every already-deployed site (they hold the old key),
     * and losing the matching private key means no future release is accepted.
     */
    private static function public_key() {
        return <<<'PEM'
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6jL0ayt7Bn9UWmiSxpkG
ZRQLE1XiLX8WcacYepD/fOYZstFxtVbhKy69xDXlJOQti5YeCP8aKRfJu3n2zVci
sOSxCtMLJdtC+0z3KnQEbnOlTbz7OmkQ42r0nQA6ivmsBgWOv+n5G8mQiTaM3noP
BlmHFqJbQ6wmz1c5M+u4BR4LFaiZugUQlJaAD8OtxwSMhiI/Z/deQHPndBV4/e7D
3ptaxNUEIDRrzFW6OJ9c0T+4Y/oqLOcuBXLNrDLHIHEMQA/ArKB9CrtPS6qg7+He
esYJyjX3xvG+Ijr/VOWa1LKFSkIkGVVsWmTr3BlMzVPfbcd/OA5cTVKRyAVHqtDK
8wIDAQAB
-----END PUBLIC KEY-----
PEM;
    }

    /**
     * Verify a detached RSA-SHA256 signature (base64) over the zip's bytes
     * against the embedded public key. Returns true, or a WP_Error. Fail-closed:
     * any missing dependency, decode failure, or mismatch is a hard "no".
     */
    public static function verify_zip_signature( $zip_path, $sig_b64 ) {
        if ( ! function_exists( 'openssl_verify' ) || ! function_exists( 'openssl_pkey_get_public' ) ) {
            return new WP_Error( 'lq_no_openssl', 'openssl unavailable on this host' );
        }
        $data = @file_get_contents( $zip_path );
        if ( false === $data || '' === $data ) {
            return new WP_Error( 'lq_read_fail', 'cannot read downloaded zip' );
        }
        $sig = base64_decode( trim( (string) $sig_b64 ), true );
        if ( false === $sig || '' === $sig ) {
            return new WP_Error( 'lq_bad_sig_encoding', 'signature is not valid base64' );
        }
        $pub = openssl_pkey_get_public( self::public_key() );
        if ( false === $pub ) {
            return new WP_Error( 'lq_bad_pubkey', 'embedded public key failed to parse' );
        }
        $ok = openssl_verify( $data, $sig, $pub, OPENSSL_ALGO_SHA256 );
        return 1 === $ok ? true : new WP_Error( 'lq_sig_mismatch', 'signature verification failed' );
    }

    /**
     * POST /linkquiver/v1/self-update
     *
     * Download the pinned zip + its signature, VERIFY, then overwrite-install
     * from the local verified file. Returns { success, before, after, verified }.
     * The running request keeps the old code in memory (normal for WP); the new
     * version loads on the next request. Fail-closed on any verification failure.
     */
    public function run( WP_REST_Request $request ) {
        if ( ! function_exists( 'get_plugin_data' ) ) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }

        $self_file = plugin_basename( LINKQUIVER_PATH . 'linkquiver.php' );
        $before    = defined( 'LINKQUIVER_VERSION' ) ? LINKQUIVER_VERSION : '';

        // The generic release build ships WITHOUT config.php. If this site's API
        // key still lives only in the legacy cleartext / preconfig source (never
        // validated, so never migrated to a hash), the overwrite would delete it
        // and orphan the site. Persist the hash NOW so the credential survives.
        Linkquiver_API_Key::ensure_hash_persisted();

        require_once ABSPATH . 'wp-admin/includes/file.php';
        require_once ABSPATH . 'wp-admin/includes/misc.php';
        require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';

        $zip_url = self::source_zip();

        // 1. Download the zip to a local temp file. We must inspect the bytes
        //    before they touch the filesystem, so never install from the URL.
        $tmp = download_url( $zip_url, 60 );
        if ( is_wp_error( $tmp ) ) {
            return new WP_REST_Response( array( 'success' => false, 'error' => 'download: ' . $tmp->get_error_message(), 'before' => $before ), 500 );
        }

        // 2. Fetch the detached signature.
        $sig = self::fetch_signature();
        if ( is_wp_error( $sig ) ) {
            @unlink( $tmp );
            return new WP_REST_Response( array( 'success' => false, 'error' => 'signature fetch failed: ' . $sig->get_error_message(), 'before' => $before ), 500 );
        }

        // 3. Verify BEFORE install. Any failure aborts without touching the plugin.
        $verify = self::verify_zip_signature( $tmp, $sig );
        if ( is_wp_error( $verify ) ) {
            @unlink( $tmp );
            return new WP_REST_Response( array( 'success' => false, 'error' => 'signature: ' . $verify->get_error_message(), 'before' => $before ), 400 );
        }

        // 4. Install from the LOCAL, verified file.
        $upgrader = new Plugin_Upgrader( new Automatic_Upgrader_Skin() );
        $result   = $upgrader->install( $tmp, array( 'overwrite_package' => true ) );
        @unlink( $tmp );

        if ( is_wp_error( $result ) ) {
            return new WP_REST_Response( array( 'success' => false, 'error' => $result->get_error_message(), 'before' => $before ), 500 );
        }
        if ( false === $result ) {
            return new WP_REST_Response( array( 'success' => false, 'error' => 'install returned false', 'before' => $before ), 500 );
        }

        $act            = activate_plugin( $self_file );
        $activate_error = is_wp_error( $act ) ? $act->get_error_message() : null;

        $after = '';
        $data  = get_plugin_data( WP_PLUGIN_DIR . '/' . $self_file, false, false );
        if ( isset( $data['Version'] ) ) {
            $after = $data['Version'];
        }

        return new WP_REST_Response( array(
            'success'        => true,
            'before'         => $before,
            'after'          => $after,
            'verified'       => true,
            'activate_error' => $activate_error,
        ), 200 );
    }
}
