<?php
# -- BEGIN LICENSE BLOCK ----------------------------------
#
# This file is part of Dotclear 2.
#
# Copyright (c) 2003-2008 Sacha and contributors
# Licensed under the GPL version 2.0 license.
# See LICENSE file or
# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html
#
# -- END LICENSE BLOCK ------------------------------------

class dcUpdate
{
	protected static $cache_ttl = '-6 hours';
	protected static $version_url = "http://download.dotclear.net/versions/stable";
	protected static $download_url = "http://download.dotclear.net/latest/";
	protected static $download_md5 = "http://download.dotclear.net/latest/checksums.txt";
	
	/**
	Checks for Dotclear updates.
	Returns:
	- latest version if available
	- null if Dotclear is up-to-date
	
	@param[out] notify	<b>boolean</b>		Notify user for updates
	@return			<b>string</b>		Latest version if available
	*/
	public static function check(&$notify=true)
	{
		$update_available = false;
		$notify = true;
		
		# Check cached file
		$cached_file = DC_TPL_CACHE.'/versions/'.md5(self::$version_url);
		
		if (is_readable($cached_file))
		{
			$c = explode("\n",file_get_contents($cached_file));
			
			if (count($c) == 2) {
				$cached_version = $c[0];
				$update_available = version_compare(DC_VERSION,$cached_version,'<');
				$notify = $c[1] == '1';
				
				if (filemtime($cached_file) > strtotime(self::$cache_ttl)) {
					return $update_available ? $cached_version : null;
				}
			}
		}
		
		$can_write = (!is_dir(dirname($cached_file)) && is_writable(DC_TPL_CACHE))
		|| (!file_exists($cached_file) && is_writable(dirname($cached_file)))
		|| is_writable($cached_file);
		
		# If we can't write file, don't bug dotclear.net with queries
		if (!$can_write) {
			return;
		}
		
		# Try to get latest version number
		$notify = true;
		if (!$res = netHttp::quickGet(self::$version_url)) {
			return;
		}
		
		if (!preg_match('/^([\da-z._-]+)\n([\da-f]{32})$/msu',$res,$m)) {
			return;
		}
		
		$version = trim($m[1]);
		$checksum = trim($m[2]);
		
		if (md5($version) !== $checksum) {
			return;
		}
		
		# Compare current version with the latest available one
		if (version_compare(DC_VERSION,$version,'<')) {
			$update_available = true;
		}
		
		# No errors detected, create cached file
		files::makeDir(dirname($cached_file));
		
		if ($update_available) {
			$c = $version."\n".($notify ? '1' : '0');
		} else {
			$c = '';
		}
		file_put_contents($cached_file,$c);
		
		return $update_available ? $version : null;
	}
	
	/**
	Hide update notifications on dashboard
	*/
	public static function hideNotifications()
	{
		$cached_file = DC_TPL_CACHE.'/versions/'.md5(self::$version_url);
		if (!is_readable($cached_file) || !is_writable($cached_file)) {
			return;
		}
		
		$c = explode("\n",file_get_contents($cached_file));
		
		if (count($c) != 2) {
			return;
		}
		
		file_put_contents($cached_file,$c[0]."\n0");
	}
	
	/**
	Checks if Dotclear archive was succefully downloaded.
	
	@param version		<b>string</b>		Dotclear version
	@return			<b>boolean</b>
	*/
	public static function checkDownload($version)
	{
		$basefile = 'dotclear-'.$version.'.zip';
		$filename = DC_ROOT.'/'.$basefile;
		
		if (!is_readable($filename)) {
			return false;
		}
		
		$url = self::$download_md5;
		$sums = explode("\n",netHttp::quickGet($url));
		
		foreach ($sums as $v) {
			if (!preg_match('#^([\da-f]{32})\s+(.+)$#',trim($v),$m)) {
				continue;
			}
			
			if ($m[2] == $basefile && md5_file($filename) == $m[1]) {
				return true;
			}
		}
		
		return false;
	}
	
	/**
	Downloads specified Dotclear archive.
	
	@param version		<b>string</b>		Dotclear version to download
	@return			<b>string</b>
	*/
	public static function download($version)
	{
		$url = self::$download_url.'dotclear-'.$version.'.zip';
		$dest = DC_ROOT.'/'.basename($url);
		
		if (!is_writable(DC_ROOT)) {
			throw new Exception(__('Dotclear root directory is not writable.'));
		}
		
		try
		{
			$client = netHttp::initClient($url,$path);
			$client->setUserAgent('Dotclear Automatic Upgrade - http://www.dotclear.net/');
			$client->useGzip(false);
			$client->setPersistReferers(false);
			$client->setOutput($dest);
			$client->get($path);
			
			if ($client->getStatus() != 200) {
				@unlink($dest);
				throw new Exception();
			}
		}
		catch (Exception $e)
		{
			throw new Exception(__('An error occurred while downloading Dotclear archive.'));
		}
	}
	
	/**
	Backups changed files before an update.
	Returns true on success, false when a backup already exists.
	
	@param version		<b>string</b>		New Dotclear version
	@return			<b>boolean</b>
	*/
	public static function backup($version)
	{
		$zip_file = DC_ROOT.'/dotclear-'.$version.'.zip';
		$bup_file = DC_ROOT.'/backup-'.DC_VERSION.'.zip';
		$cur_digests = DC_ROOT.'/inc/digests';
		
		if (!is_readable($zip_file)) {
			throw new Exception(__('Dotclear archive not found.'));
		}
		
		if (!is_readable($cur_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(DC_ROOT) && !file_exists($bup_file)) {
			throw new Exception(__('Dotclear root directory is not writable.'));
		}
		
		if (file_exists($bup_file) && !is_writable($bup_file)) {
			return false;
		}
		
		$bup_fp = @fopen($bup_file,'wb');
		if ($bup_fp === false) {
			return false;
		}
		
		$zip = new fileUnzip($zip_file);
		$bup_zip = new fileZip($bup_fp);
		
		if (!$zip->hasFile('dotclear/inc/digests'))
		{
			@unlink($zip_file);
			throw new Exception(__('Downloaded file seems not to be a valid Dotclear archive.'));
		}
		
		$opts = FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES;
		$cur_digests = file($cur_digests,$opts);
		$new_digests = explode("\n",$zip->unzip('dotclear/inc/digests'));
		$new_files = self::getNewFiles($cur_digests,$new_digests);
		unset($opts,$cur_digests,$new_digests,$zip);
		
		$not_readable = array();
		
		foreach ($new_files as $file)
		{
			if (!$file || !file_exists(DC_ROOT.'/'.$file)) {
				continue;
			}
			
			try {
				$bup_zip->addFile(DC_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(sprintf(
				__('The following files of your Dotclear installation are not readable. '.
				'Please fix this or try to make a backup file named %s manually.'),
				'<strong>backup-'.DC_VERSION.'.zip</strong>'
			));
			$e->bad_files = $not_readable;
			throw $e;
		}
		
		$bup_zip->write();
		fclose($bup_fp);
		
		return true;
	}
	
	/**
	Updates Dotclear core.
	
	@param version		<b>string</b>
	*/
	public static function performUpgrade($version)
	{
		$zip_file = DC_ROOT.'/dotclear-'.$version.'.zip';
		$cur_digests = DC_ROOT.'/inc/digests';
		
		if (!is_readable($zip_file)) {
			throw new Exception(__('Dotclear archive not found.'));
		}
		
		if (!is_readable($cur_digests)) {
			@unlink($zip_file);
			throw new Exception(__('Unable to read current digests file.'));
		}
		
		$zip = new fileUnzip($zip_file);
		
		if (!$zip->hasFile('dotclear/inc/digests'))
		{
			@unlink($zip_file);
			throw new Exception(__('Downloaded file seems not to be a valid Dotclear archive.'));
		}
		
		$opts = FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES;
		$cur_digests = file($cur_digests,$opts);
		$new_digests = explode("\n",$zip->unzip('dotclear/inc/digests'));
		$new_files = self::getNewFiles($cur_digests,$new_digests);
		
		$zip_files = array();
		$not_writable = array();
		
		foreach ($new_files as $file)
		{
			if (!$file) {
				continue;
			}
			
			if (!$zip->hasFile('dotclear/'.$file)) {
				@unlink($zip_file);
				throw new Exception(__('Incomplete Dotclear archive.'));
			}
			
			$dest = DC_ROOT.'/'.$file;
			if ((file_exists($dest) && !is_writable($dest)) ||
			(!file_exists($dest) && !is_writable(dirname($dest)))) {
				$not_writable[] = $file;
				continue;
			}
			
			$zip_files[] = $file;
		}
		
		# If only one file is not writable, stop everything now
		if (!empty($not_writable)) {
			$e = new Exception(
				__('The following files of your Dotclear installation cannot be written. '.
				'Please fix this or try to update manually.')
			);
			$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('dotclear/'.$file,DC_ROOT.'/'.$file);
			if ($can_touch) {
				@touch(DC_ROOT.'/'.$file);
			}
		}
		@unlink($zip_file);
		@unlink(DC_TPL_CACHE.'/versions/'.md5(self::$version_url));
	}
	
	/**
	Returns an array of new or modified files, that should replace the old
	ones.
	
	@param cur_digests	<b>array</b>		Contents of current digests file
	@param new_digests	<b>array</b>		Contents of new digests file
	@return			<b>array</b>		New files
	*/
	public static function getNewFiles($cur_digests,$new_digests)
	{
		$cur_md5 = $cur_path = $cur_digests;
		$new_md5 = $new_path = $new_digests;
		
		array_walk($cur_md5,array('self','parseLine'),1);
		array_walk($cur_path,array('self','parseLine'),2);
		array_walk($new_md5,array('self','parseLine'),1);
		array_walk($new_path,array('self','parseLine'),2);
		
		$cur = array_combine($cur_md5,$cur_path);
		$new = array_combine($new_md5,$new_path);
		
		return array_values(array_diff_key($new,$cur));
	}
	
	/**
	Checks installation integrity. Throws an exception on error and returns
	true when installation is unchanged.
	
	@return			<b>boolean</b>
	*/
	public static function checkIntegrity()
	{
		$digests_file = path::real(DC_ROOT.'/inc/digests');
		
		if (!$digests_file) {
			throw new Exception(__('Digests file not found.'));
		}
		
		$changes = self::md5sum(DC_ROOT,$digests_file);
		
		if (!empty($changes)) {
			$e = new Exception(
				__('The following files of your Dotclear installation '.
				'have been modified so we won\'t try to update your installation. '.
				'Please try to update manually.')
			);
			$e->bad_files = $changes;
			throw $e;
		}
		
		return true;
	}
	
	/**
	This function comes in replacement of coreutils md5sum utility.
	Returns true when all files are writable and unchanged.
	
	@param root		<b>string</b>		Root directory
	@param digests_file	<b>string</b>		Path to digests file
	@return			<b>boolean</b>
	*/
	private static 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 (md5_file($filename) !== $md5) {
				$changes[] = substr($m[2],2);
			}
		}
		
		# No checksum found in digests file
		if (empty($md5)) {
			throw new Exception(__('Invalid digests file.'));
		}
		
		return $changes;
	}
	
	/**
	Parses a single line of a digests file. Matches pointer allows to select
	MD5 checksum (1) or file path (2).
	
	@param[out]	v	<b>string</b>		Current line
	@param		k	<b>integer</b>		Line number (unused)
	@param		n	<b>integer</b>		Matches pointer
	*/
	private static 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);
	}
}
?>