<?php
/**
 * @package Dotclear
 * @subpackage Core
 *
 * @copyright Olivier Meunier & Association Dotclear
 * @copyright GPL-2.0-only
 */

if (!defined('DC_RC_PATH')) {return;}

class dcUpdate
{
    const ERR_FILES_CHANGED    = 101;
    const ERR_FILES_UNREADABLE = 102;
    const ERR_FILES_UNWRITALBE = 103;

    protected $url;
    protected $subject;
    protected $version;
    protected $cache_file;

    protected $version_info = array(
        'version'  => null,
        'href'     => null,
        'checksum' => null,
        'info'     => null,
        'php'      => '5.6',
        'notify'   => true
    );

    protected $cache_ttl    = '-6 hours';
    protected $forced_files = array();

    /**
     * Constructor
     *
     * @param url            string    Versions file URL
     * @param subject        string    Subject to check
     * @param version        string    Version type
     * @param cache_dir     string    Directory cache path
     */
    public function __construct($url, $subject, $version, $cache_dir)
    {
        $this->url        = $url;
        $this->subject    = $subject;
        $this->version    = $version;
        $this->cache_file = $cache_dir . '/' . $subject . '-' . $version;
    }

    /**
     * Checks for Dotclear updates.
     * Returns latest version if available or false.
     *
     * @param version        string    Current version to compare
     * @param nocache        boolean   Force checking
     * @return string                Latest version if available
     */
    public function check($version, $nocache = false)
    {
        $this->getVersionInfo($nocache);
        $v = $this->getVersion();
        if ($v && version_compare($version, $v, '<')) {
            return $v;
        }

        return false;
    }

    public function getVersionInfo($nocache = false)
    {
        # Check cached file
        if (is_readable($this->cache_file) && filemtime($this->cache_file) > strtotime($this->cache_ttl) && !$nocache) {
            $c = @file_get_contents($this->cache_file);
            $c = @unserialize($c);
            if (is_array($c)) {
                $this->version_info = $c;
                return;
            }
        }

        $cache_dir = dirname($this->cache_file);
        $can_write = (!is_dir($cache_dir) && is_writable(dirname($cache_dir)))
        || (!file_exists($this->cache_file) && is_writable($cache_dir))
        || is_writable($this->cache_file);

        # If we can't write file, don't bug host with queries
        if (!$can_write) {
            return;
        }

        if (!is_dir($cache_dir)) {
            try {
                files::makeDir($cache_dir);
            } catch (Exception $e) {
                return;
            }
        }

        # Try to get latest version number
        try
        {
            $path   = '';
            $status = 0;

            $http_get = function ($http_url) use (&$status, $path) {
                $client = netHttp::initClient($http_url, $path);
                if ($client !== false) {
                    $client->setTimeout(4);
                    $client->setUserAgent($_SERVER['HTTP_USER_AGENT']);
                    $client->get($path);
                    $status = (int) $client->getStatus();
                }
                return $client;
            };

            $client = $http_get($this->url);
            if ($status >= 400) {
                // If original URL uses HTTPS, try with HTTP
                $url_parts = parse_url($client->getRequestURL());
                if (isset($url_parts['scheme']) && $url_parts['scheme'] == 'https') {
                    // Replace https by http in url
                    $this->url = preg_replace('/^https(?=:\/\/)/i', 'http', $this->url);
                    $client    = $http_get($this->url);
                }
            }
            if (!$status || $status >= 400) {
                throw new Exception();
            }
            $this->readVersion($client->getContent());
        } catch (Exception $e) {
            return;
        }

        # Create cache
        file_put_contents($this->cache_file, serialize($this->version_info));
    }

    public function getVersion()
    {
        return $this->version_info['version'];
    }

    public function getFileURL()
    {
        return $this->version_info['href'];
    }

    public function getInfoURL()
    {
        return $this->version_info['info'];
    }

    public function getChecksum()
    {
        return $this->version_info['checksum'];
    }

    public function getPHPVersion()
    {
        return $this->version_info['php'];
    }

    public function getNotify()
    {
        return $this->version_info['notify'];
    }

    public function getForcedFiles()
    {
        return $this->forced_files;
    }

    public function setForcedFiles()
    {
        $this->forced_files = func_get_args();
    }

    /**
     * Sets notification flag.
     */
    public function setNotify($n)
    {

        if (!is_writable($this->cache_file)) {
            return;
        }

        $this->version_info['notify'] = (boolean) $n;
        file_put_contents($this->cache_file, serialize($this->version_info));
    }

    public function checkIntegrity($digests_file, $root)
    {
        if (!$digests_file) {
            throw new Exception(__('Digests file not found.'));
        }

        $changes = $this->md5sum($root, $digests_file);

        if (!empty($changes)) {
            $e            = new Exception('Some files have changed.', self::ERR_FILES_CHANGED);
            $e->bad_files = $changes;
            throw $e;
        }

        return true;
    }

    /**
     * Downloads new version to destination $dest.
     */
    public function download($dest)
    {
        $url = $this->getFileURL();

        if (!$url) {
            throw new Exception(__('No file to download'));
        }

        if (!is_writable(dirname($dest))) {
            throw new Exception(__('Root directory is not writable.'));
        }

        try
        {
            $path   = '';
            $status = 0;

            $http_get = function ($http_url) use (&$status, $dest, $path) {
                $client = netHttp::initClient($http_url, $path);
                if ($client !== false) {
                    $client->setTimeout(4);
                    $client->setUserAgent($_SERVER['HTTP_USER_AGENT']);
                    $client->useGzip(false);
                    $client->setPersistReferers(false);
                    $client->setOutput($dest);
                    $client->get($path);
                    $status = (int) $client->getStatus();
                }
                return $client;
            };

            $client = $http_get($url);
            if ($status >= 400) {
                // If original URL uses HTTPS, try with HTTP
                $url_parts = parse_url($client->getRequestURL());
                if (isset($url_parts['scheme']) && $url_parts['scheme'] == 'https') {
                    // Replace https by http in url
                    $url    = preg_replace('/^https(?=:\/\/)/i', 'http', $url);
                    $client = $http_get($url);
                }
            }
            if ($status != 200) {
                @unlink($dest);
                throw new Exception();
            }
        } catch (Exception $e) {
            throw new Exception(__('An error occurred while downloading archive.'));
        }
    }

    /**
     * Checks if archive was successfully downloaded.
     */
    public function checkDownload($zip)
    {
        $cs = $this->getChecksum();

        return $cs && is_readable($zip) && md5_file($zip) == $cs;
    }

    /**
     * Backups changed files before an update.
     */
    public function backup($zip_file, $zip_digests, $root, $root_digests, $dest)
    {
        if (!is_readable($zip_file)) {
            throw new Exception(__('Archive not found.'));
        }

        if (!is_readable($root_digests)) {
            @unlink($zip_file);
            throw new Exception(__('Unable to read current digests file.'));
        }

        # Stop everything if a backup already exists and can not be overrided
        if (!is_writable(dirname($dest)) && !file_exists($dest)) {
            throw new Exception(__('Root directory is not writable.'));
        }

        if (file_exists($dest) && !is_writable($dest)) {
            return false;
        }

        $b_fp = @fopen($dest, 'wb');
        if ($b_fp === false) {
            return false;
        }

        $zip   = new fileUnzip($zip_file);
        $b_zip = new fileZip($b_fp);

        if (!$zip->hasFile($zip_digests)) {
            @unlink($zip_file);
            throw new Exception(__('Downloaded file does not seem to be a valid archive.'));
        }

        $opts        = FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES;
        $cur_digests = file($root_digests, $opts);
        $new_digests = explode("\n", $zip->unzip($zip_digests));
        $new_files   = $this->getNewFiles($cur_digests, $new_digests);
        $zip->close();
        unset($opts, $cur_digests, $new_digests, $zip);

        $not_readable = array();

        if (!empty($this->forced_files)) {
            $new_files = array_merge($new_files, $this->forced_files);
        }

        foreach ($new_files as $file) {
            if (!$file || !file_exists($root . '/' . $file)) {
                continue;
            }

            try {
                $b_zip->addFile($root . '/' . $file, $file);
            } catch (Exception $e) {
                $not_readable[] = $file;
            }
        }

        # If only one file is not readable, stop everything now
        if (!empty($not_readable)) {
            $e            = new Exception('Some files are not readable.', self::ERR_FILES_UNREADABLE);
            $e->bad_files = $not_readable;
            throw $e;
        }

        $b_zip->write();
        fclose($b_fp);
        $b_zip->close();

        return true;
    }

    /**
     * Upgrade process.
     */
    public function performUpgrade($zip_file, $zip_digests, $zip_root, $root, $root_digests)
    {
        if (!is_readable($zip_file)) {
            throw new Exception(__('Archive not found.'));
        }

        if (!is_readable($root_digests)) {
            @unlink($zip_file);
            throw new Exception(__('Unable to read current digests file.'));
        }

        $zip = new fileUnzip($zip_file);

        if (!$zip->hasFile($zip_digests)) {
            @unlink($zip_file);
            throw new Exception(__('Downloaded file does not seem to be a valid archive.'));
        }

        $opts        = FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES;
        $cur_digests = file($root_digests, $opts);
        $new_digests = explode("\n", $zip->unzip($zip_digests));
        $new_files   = self::getNewFiles($cur_digests, $new_digests);

        if (!empty($this->forced_files)) {
            $new_files = array_merge($new_files, $this->forced_files);
        }

        $zip_files    = array();
        $not_writable = array();

        foreach ($new_files as $file) {
            if (!$file) {
                continue;
            }

            if (!$zip->hasFile($zip_root . '/' . $file)) {
                @unlink($zip_file);
                throw new Exception(__('Incomplete archive.'));
            }

            $dest = $dest_dir = $root . '/' . $file;
            while (!is_dir($dest_dir = dirname($dest_dir)));

            if ((file_exists($dest) && !is_writable($dest)) ||
                (!file_exists($dest) && !is_writable($dest_dir))) {
                $not_writable[] = $file;
                continue;
            }

            $zip_files[] = $file;
        }

        # If only one file is not writable, stop everything now
        if (!empty($not_writable)) {
            $e            = new Exception('Some files are not writable', self::ERR_FILES_UNWRITALBE);
            $e->bad_files = $not_writable;
            throw $e;
        }

        # Everything's fine, we can write files, then do it now
        $can_touch = function_exists('touch');
        foreach ($zip_files as $file) {
            $zip->unzip($zip_root . '/' . $file, $root . '/' . $file);
            if ($can_touch) {
                @touch($root . '/' . $file);
            }
        }
        @unlink($zip_file);
    }

    protected function getNewFiles($cur_digests, $new_digests)
    {
        $cur_md5 = $cur_path = $cur_digests;
        $new_md5 = $new_path = $new_digests;

        array_walk($cur_md5, array($this, 'parseLine'), 1);
        array_walk($cur_path, array($this, 'parseLine'), 2);
        array_walk($new_md5, array($this, 'parseLine'), 1);
        array_walk($new_path, array($this, 'parseLine'), 2);

        $cur = array_combine($cur_md5, $cur_path);
        $new = array_combine($new_md5, $new_path);

        return array_values(array_diff_key($new, $cur));
    }

    protected function readVersion($str)
    {
        try
        {
            $xml = new SimpleXMLElement($str, LIBXML_NOERROR);
            $r   = $xml->xpath("/versions/subject[@name='" . $this->subject . "']/release[@name='" . $this->version . "']");

            if (!empty($r) && is_array($r)) {
                $r                              = $r[0];
                $this->version_info['version']  = isset($r['version']) ? (string) $r['version'] : null;
                $this->version_info['href']     = isset($r['href']) ? (string) $r['href'] : null;
                $this->version_info['checksum'] = isset($r['checksum']) ? (string) $r['checksum'] : null;
                $this->version_info['info']     = isset($r['info']) ? (string) $r['info'] : null;
                $this->version_info['php']      = isset($r['php']) ? (string) $r['php'] : null;
            }
        } catch (Exception $e) {
            throw $e;
        }
    }

    protected function md5sum($root, $digests_file)
    {
        if (!is_readable($digests_file)) {
            throw new Exception(__('Unable to read digests file.'));
        }

        $opts     = FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES;
        $contents = file($digests_file, $opts);

        $changes = array();

        foreach ($contents as $digest) {
            if (!preg_match('#^([\da-f]{32})\s+(.+?)$#', $digest, $m)) {
                continue;
            }

            $md5      = $m[1];
            $filename = $root . '/' . $m[2];

            # Invalid checksum
            if (!is_readable($filename) || !self::md5_check($filename, $md5)) {
                $changes[] = substr($m[2], 2);
            }
        }

        # No checksum found in digests file
        if (empty($md5)) {
            throw new Exception(__('Invalid digests file.'));
        }

        return $changes;
    }

    protected function parseLine(&$v, $k, $n)
    {
        if (!preg_match('#^([\da-f]{32})\s+(.+?)$#', $v, $m)) {
            return;
        }

        $v = $n == 1 ? md5($m[2] . $m[1]) : substr($m[2], 2);
    }

    protected static function md5_check($filename, $md5)
    {
        if (md5_file($filename) == $md5) {
            return true;
        } else {
            $filecontent = file_get_contents($filename);
            $filecontent = str_replace("\r\n", "\n", $filecontent);
            $filecontent = str_replace("\r", "\n", $filecontent);
            if (md5($filecontent) == $md5) {
                return true;
            }

        }
        return false;
    }
}
