<?php
/**
 * Plugin Name: Link Web Updater
 * Description: Manages Link Web MU plugins and regular WordPress plugins from plugins.linkweb.ca.
 * Version: 1.1.0
 * LWD Type: mu-plugin
 * LWD Install: existing-only
 * LWD File: lwd-updater.php
 * Requires PHP: 5.6.0
 * Requires at least: 5.0
 */

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

define( 'LWD_UPDATER_VERSION', '1.1.0' );
define( 'LWD_UPDATER_MANIFEST_URL', 'https://plugins.linkweb.ca/manifest.json' );
define( 'LWD_UPDATER_HOST', 'plugins.linkweb.ca' );
define( 'LWD_UPDATER_SELF_FILE', 'lwd-updater.php' );

/**
 * Write updater messages to the PHP error log.
 */
function lwd_updater_log( $message ) {
    error_log( '[LWD Updater] ' . $message );
}

/**
 * Read a Version header from a PHP file.
 */
function lwd_updater_get_file_version( $path ) {
    if ( ! is_file( $path ) || ! is_readable( $path ) ) {
        return '0.0.0';
    }

    $data = get_file_data(
        $path,
        array(
            'Version' => 'Version',
        ),
        'plugin'
    );

    if ( empty( $data['Version'] ) ) {
        return '0.0.0';
    }

    return trim( $data['Version'] );
}

/**
 * Validate that a release URL points to the dedicated Link Web update host.
 */
function lwd_updater_valid_download_url( $url ) {
    $parts = wp_parse_url( $url );

    if ( ! is_array( $parts ) ) {
        return false;
    }

    if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
        return false;
    }

    if ( 'https' !== strtolower( $parts['scheme'] ) ) {
        return false;
    }

    if ( LWD_UPDATER_HOST !== strtolower( $parts['host'] ) ) {
        return false;
    }

    return true;
}

/**
 * Validate common manifest fields and compatibility.
 */
function lwd_updater_validate_product( $product_id, $product ) {
    $required = array(
        'type',
        'install',
        'file',
        'version',
        'download_url',
        'sha256',
    );

    foreach ( $required as $field ) {
        if ( ! isset( $product[ $field ] ) || '' === trim( (string) $product[ $field ] ) ) {
            lwd_updater_log( $product_id . ': manifest is missing ' . $field . '.' );
            return false;
        }
    }

    if ( ! in_array( $product['type'], array( 'mu-plugin', 'plugin' ), true ) ) {
        lwd_updater_log( $product_id . ': unsupported product type.' );
        return false;
    }

    if ( ! in_array( $product['install'], array( 'required', 'existing-only' ), true ) ) {
        lwd_updater_log( $product_id . ': invalid install policy.' );
        return false;
    }

    if ( ! lwd_updater_valid_download_url( $product['download_url'] ) ) {
        lwd_updater_log( $product_id . ': invalid download URL.' );
        return false;
    }

    $expected_hash = strtolower( trim( $product['sha256'] ) );

    if ( ! preg_match( '/^[a-f0-9]{64}$/', $expected_hash ) ) {
        lwd_updater_log( $product_id . ': invalid SHA-256 value.' );
        return false;
    }

    if (
        ! empty( $product['minimum_php'] ) &&
        version_compare( PHP_VERSION, $product['minimum_php'], '<' )
    ) {
        lwd_updater_log(
            $product_id . ': version ' . $product['version'] .
            ' requires PHP ' . $product['minimum_php'] .
            '; this site is running PHP ' . PHP_VERSION . '.'
        );
        return false;
    }

    if ( ! empty( $product['minimum_wordpress'] ) ) {
        global $wp_version;

        if ( version_compare( $wp_version, $product['minimum_wordpress'], '<' ) ) {
            lwd_updater_log(
                $product_id . ': version ' . $product['version'] .
                ' requires WordPress ' . $product['minimum_wordpress'] .
                '; this site is running WordPress ' . $wp_version . '.'
            );
            return false;
        }
    }

    return true;
}

/**
 * Download a package to a temporary file and verify its SHA-256.
 *
 * Returns the temporary filename on success or false on failure.
 */
function lwd_updater_download_verified_package( $product_id, $product ) {
    if ( ! lwd_updater_valid_download_url( $product['download_url'] ) ) {
        lwd_updater_log( $product_id . ': invalid download URL.' );
        return false;
    }

    $expected_hash = strtolower( trim( $product['sha256'] ) );

    if ( ! preg_match( '/^[a-f0-9]{64}$/', $expected_hash ) ) {
        lwd_updater_log( $product_id . ': invalid SHA-256 value.' );
        return false;
    }

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

    $temp_file = download_url(
        $product['download_url'],
        30,
        false
    );

    if ( is_wp_error( $temp_file ) ) {
        lwd_updater_log(
            $product_id . ': download failed: ' . $temp_file->get_error_message()
        );
        return false;
    }

    if ( ! is_file( $temp_file ) || ! is_readable( $temp_file ) ) {
        @unlink( $temp_file );
        lwd_updater_log( $product_id . ': downloaded package is missing or unreadable.' );
        return false;
    }

    $actual_hash = hash_file( 'sha256', $temp_file );

    if (
        false === $actual_hash ||
        ! hash_equals( $expected_hash, strtolower( $actual_hash ) )
    ) {
        @unlink( $temp_file );
        lwd_updater_log( $product_id . ': SHA-256 verification failed.' );
        return false;
    }

    return $temp_file;
}

/**
 * Install or update one MU plugin.
 */
function lwd_updater_update_mu_plugin( $product_id, $product ) {
    if ( ! lwd_updater_validate_product( $product_id, $product ) ) {
        return false;
    }

    $file = (string) $product['file'];

    if (
        basename( $file ) !== $file ||
        '.php' !== substr( strtolower( $file ), -4 )
    ) {
        lwd_updater_log( $product_id . ': invalid MU-plugin filename.' );
        return false;
    }

    $target = WPMU_PLUGIN_DIR . '/' . $file;
    $exists = is_file( $target );

    /*
     * existing-only means the manifest manages this component only where it
     * has already been installed manually.
     */
    if ( ! $exists && 'existing-only' === $product['install'] ) {
        return true;
    }

    $installed_version = $exists
        ? lwd_updater_get_file_version( $target )
        : '0.0.0';

    if (
        $exists &&
        version_compare( $product['version'], $installed_version, '<=' )
    ) {
        return true;
    }

    if ( ! is_dir( WPMU_PLUGIN_DIR ) || ! is_writable( WPMU_PLUGIN_DIR ) ) {
        lwd_updater_log(
            $product_id . ': ' . WPMU_PLUGIN_DIR . ' is not writable.'
        );
        return false;
    }

    $temp_download = lwd_updater_download_verified_package(
        $product_id,
        $product
    );

    if ( false === $temp_download ) {
        return false;
    }

    $contents = file_get_contents( $temp_download );
    @unlink( $temp_download );

    if ( false === $contents || '' === $contents ) {
        lwd_updater_log( $product_id . ': downloaded MU-plugin release is empty.' );
        return false;
    }

    if ( false === strpos( substr( ltrim( $contents ), 0, 20 ), '<?php' ) ) {
        lwd_updater_log(
            $product_id . ': downloaded release does not look like PHP source.'
        );
        return false;
    }

    /*
     * Verify that the downloaded PHP file identifies itself as the expected
     * version before replacing the live copy.
     */
    $temp_header_file = wp_tempnam( $file );

    if ( ! $temp_header_file ) {
        lwd_updater_log( $product_id . ': could not create header-check file.' );
        return false;
    }

    $header_written = @file_put_contents(
        $temp_header_file,
        $contents,
        LOCK_EX
    );

    if (
        false === $header_written ||
        strlen( $contents ) !== $header_written
    ) {
        @unlink( $temp_header_file );
        lwd_updater_log( $product_id . ': could not write header-check file.' );
        return false;
    }

    $downloaded_version = lwd_updater_get_file_version( $temp_header_file );
    @unlink( $temp_header_file );

    if (
        '0.0.0' === $downloaded_version ||
        0 !== version_compare( $downloaded_version, $product['version'] )
    ) {
        lwd_updater_log(
            $product_id . ': downloaded Version header does not match manifest version.'
        );
        return false;
    }

    $temp   = $target . '.new';
    $backup = $target . '.bak';

    $written = @file_put_contents(
        $temp,
        $contents,
        LOCK_EX
    );

    if (
        false === $written ||
        strlen( $contents ) !== $written
    ) {
        @unlink( $temp );
        lwd_updater_log(
            $product_id . ': could not write temporary release file.'
        );
        return false;
    }

    @chmod( $temp, 0644 );

    $expected_hash = strtolower( trim( $product['sha256'] ) );
    $written_hash  = hash_file( 'sha256', $temp );

    if (
        false === $written_hash ||
        ! hash_equals( $expected_hash, strtolower( $written_hash ) )
    ) {
        @unlink( $temp );
        lwd_updater_log(
            $product_id . ': temporary release failed SHA-256 verification.'
        );
        return false;
    }

    if ( $exists && ! @copy( $target, $backup ) ) {
        @unlink( $temp );
        lwd_updater_log(
            $product_id . ': could not create backup; update aborted.'
        );
        return false;
    }

    if ( ! @rename( $temp, $target ) ) {
        @unlink( $temp );
        lwd_updater_log(
            $product_id . ': could not replace the installed file.'
        );
        return false;
    }

    @chmod( $target, 0644 );

    if ( function_exists( 'opcache_invalidate' ) ) {
        @opcache_invalidate( $target, true );
    }

    lwd_updater_log(
        $product_id . ': ' .
        ( $exists ? 'updated from ' . $installed_version : 'installed' ) .
        ' to ' . $product['version'] . '.'
    );

    return true;
}

/**
 * Return the installed version of a regular WordPress plugin.
 */
function lwd_updater_get_plugin_version( $plugin_file ) {
    $path = WP_PLUGIN_DIR . '/' . $plugin_file;

    return lwd_updater_get_file_version( $path );
}

/**
 * Restore the update_plugins site transient exactly as it was before a
 * private-plugin upgrade.
 */
function lwd_updater_restore_plugin_updates_transient( $previous ) {
    if ( false === $previous ) {
        delete_site_transient( 'update_plugins' );
        return;
    }

    set_site_transient( 'update_plugins', $previous );
}

/**
 * Install or update one regular WordPress plugin.
 */
function lwd_updater_update_plugin( $product_id, $product ) {
    if ( ! lwd_updater_validate_product( $product_id, $product ) ) {
        return false;
    }

    $plugin_file = str_replace( '\\', '/', (string) $product['file'] );
    $parts       = explode( '/', $plugin_file );

    /*
     * Regular plugins use:
     *     plugin-folder/main-plugin.php
     */
    if (
        2 !== count( $parts ) ||
        '' === $parts[0] ||
        '' === $parts[1] ||
        basename( $parts[0] ) !== $parts[0] ||
        basename( $parts[1] ) !== $parts[1] ||
        '.php' !== substr( strtolower( $parts[1] ), -4 )
    ) {
        lwd_updater_log(
            $product_id . ': invalid regular-plugin file path.'
        );
        return false;
    }

    $plugin_path = WP_PLUGIN_DIR . '/' . $plugin_file;
    $exists      = is_file( $plugin_path );

    if ( ! $exists && 'existing-only' === $product['install'] ) {
        return true;
    }

    $installed_version = $exists
        ? lwd_updater_get_plugin_version( $plugin_file )
        : '0.0.0';

    if (
        $exists &&
        version_compare( $product['version'], $installed_version, '<=' )
    ) {
        return true;
    }

    if ( ! is_dir( WP_PLUGIN_DIR ) || ! is_writable( WP_PLUGIN_DIR ) ) {
        lwd_updater_log(
            $product_id . ': ' . WP_PLUGIN_DIR . ' is not writable.'
        );
        return false;
    }

    $package = lwd_updater_download_verified_package(
        $product_id,
        $product
    );

    if ( false === $package ) {
        return false;
    }

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

    $skin     = new Automatic_Upgrader_Skin();
    $upgrader = new Plugin_Upgrader( $skin );
    $result   = false;

    if ( $exists ) {
        /*
         * Plugin_Upgrader::upgrade() expects an entry in the update_plugins
         * transient. Temporarily inject our already-downloaded, SHA-verified
         * local ZIP as the package, then restore the transient afterward.
         */
        $previous_transient = get_site_transient( 'update_plugins' );
        $update_transient   = $previous_transient;

        if ( ! is_object( $update_transient ) ) {
            $update_transient = new stdClass();
        }

        if (
            ! isset( $update_transient->response ) ||
            ! is_array( $update_transient->response )
        ) {
            $update_transient->response = array();
        }

        $offer              = new stdClass();
        $offer->slug        = $parts[0];
        $offer->plugin      = $plugin_file;
        $offer->new_version = $product['version'];
        $offer->package     = $package;

        $update_transient->response[ $plugin_file ] = $offer;

        set_site_transient(
            'update_plugins',
            $update_transient
        );

        $result = $upgrader->upgrade(
            $plugin_file,
            array(
                'clear_update_cache' => false,
            )
        );

        lwd_updater_restore_plugin_updates_transient(
            $previous_transient
        );
    } else {
        /*
         * A required plugin that is not installed is installed from the
         * SHA-verified local ZIP.
         */
        $result = $upgrader->install(
            $package,
            array(
                'overwrite_package' => false,
            )
        );
    }

    @unlink( $package );

    if ( is_wp_error( $result ) ) {
        lwd_updater_log(
            $product_id . ': WordPress upgrader failed: ' .
            $result->get_error_message()
        );
        return false;
    }

    if ( false === $result ) {
        $errors = $skin->get_errors();

        if ( is_wp_error( $errors ) && $errors->has_errors() ) {
            lwd_updater_log(
                $product_id . ': WordPress upgrader failed: ' .
                $errors->get_error_message()
            );
        } else {
            lwd_updater_log(
                $product_id . ': WordPress upgrader returned failure.'
            );
        }

        return false;
    }

    /*
     * Confirm that WordPress installed the expected plugin file and version.
     */
    if ( ! is_file( $plugin_path ) ) {
        lwd_updater_log(
            $product_id . ': update completed but the expected plugin file is missing.'
        );
        return false;
    }

    $new_version = lwd_updater_get_plugin_version( $plugin_file );

    if (
        '0.0.0' === $new_version ||
        version_compare( $new_version, $product['version'], '<' )
    ) {
        lwd_updater_log(
            $product_id . ': update completed but installed version is ' .
            $new_version . '; expected ' . $product['version'] . '.'
        );
        return false;
    }

    lwd_updater_log(
        $product_id . ': ' .
        ( $exists ? 'updated from ' . $installed_version : 'installed' ) .
        ' to ' . $new_version . '.'
    );

    return true;
}

/**
 * Fetch the central manifest.
 */
function lwd_updater_get_manifest() {
    $response = wp_remote_get(
        LWD_UPDATER_MANIFEST_URL,
        array(
            'timeout'     => 15,
            'redirection' => 2,
            'sslverify'   => true,
            'headers'     => array(
                'Accept'        => 'application/json',
                'Cache-Control' => 'no-cache',
            ),
            'user-agent' => 'LWD-Updater/' . LWD_UPDATER_VERSION,
        )
    );

    if ( is_wp_error( $response ) ) {
        lwd_updater_log(
            'Manifest request failed: ' . $response->get_error_message()
        );
        return false;
    }

    $status = (int) wp_remote_retrieve_response_code( $response );

    if ( 200 !== $status ) {
        lwd_updater_log(
            'Manifest request returned HTTP ' . $status . '.'
        );
        return false;
    }

    $manifest = json_decode(
        wp_remote_retrieve_body( $response ),
        true
    );

    if (
        ! is_array( $manifest ) ||
        empty( $manifest['products'] ) ||
        ! is_array( $manifest['products'] )
    ) {
        lwd_updater_log(
            'Manifest is invalid or has no products.'
        );
        return false;
    }

    return $manifest;
}

/**
 * Run an update check.
 *
 * $force=true is useful from WP-CLI while testing.
 */
function lwd_updater_run( $force = false ) {
    if ( ! $force ) {
        $next_check = (int) get_option(
            'lwd_updater_next_check',
            0
        );

        if ( $next_check > time() ) {
            return;
        }

        /*
         * Set the next check before making network requests. This reduces
         * duplicate checks when several front-end requests arrive together.
         */
        update_option(
            'lwd_updater_next_check',
            time() + ( 6 * HOUR_IN_SECONDS ) + wp_rand( 0, HOUR_IN_SECONDS ),
            false
        );
    }

    $manifest = lwd_updater_get_manifest();

    if ( false === $manifest ) {
        return;
    }

    $self_product = false;

    foreach ( $manifest['products'] as $product_id => $product ) {
        if ( ! is_array( $product ) || empty( $product['type'] ) ) {
            continue;
        }

        /*
         * Always update this updater itself last so a new updater release
         * cannot replace its own code halfway through processing the manifest.
         */
        if (
            'mu-plugin' === $product['type'] &&
            ! empty( $product['file'] ) &&
            LWD_UPDATER_SELF_FILE === $product['file']
        ) {
            $self_product = array(
                'id'      => $product_id,
                'product' => $product,
            );

            continue;
        }

        if ( 'mu-plugin' === $product['type'] ) {
            lwd_updater_update_mu_plugin(
                $product_id,
                $product
            );
            continue;
        }

        if ( 'plugin' === $product['type'] ) {
            lwd_updater_update_plugin(
                $product_id,
                $product
            );
            continue;
        }

        lwd_updater_log(
            $product_id . ': unsupported product type.'
        );
    }

    if ( false !== $self_product ) {
        lwd_updater_update_mu_plugin(
            $self_product['id'],
            $self_product['product']
        );
    }
}

add_action( 'init', 'lwd_updater_run', 20 );
