execute($source,$target);
print <<
Скрипт, обновляющий сам себя ({$version})
Обновление скрипта на {$version}
Обновление выполнено успешно
HTML;
break;
}
default: {
/*
* примитивный функционал скрипта
*/
#CUSTOM_VERSION_CODE
$version = 'V1';
header("Content-type: text/html; charset=utf-8");
$custom = 'V1: Hello World!
';
#CUSTOM_VERSION_CODE
print <<
Скрипт, обновляющий сам себя ({$version})
{$custom}
Обновить скрипт
HTML;
break;
}
}
}catch(Exception $ex){
header("Content-type: text/plain; charset=utf-8");
print "\n".str_repeat('==',30)."\n";
print "Exception with code {$ex->getCode()}:\n\t{$ex->getMessage()}\n";
print "\nTrace:\n";
print $ex->getTraceAsString();
}
/*
-------------------------------------------------------- */
/**
*
* selfUpdate
*
* Класс для обновления одного файла файлом из удаленного источника.
* Адрес источника передается в конструктор. Скрипт загружает файл во временную папку
*
*/
class selfUpdate
{
const TIMEOUT_SOCKET = 15;
/**
*
* @var string директория на сервере, куда загружается обновление, чтобы потом заменить работающий скрипт index.php
*/
const PATH_UPDATE = 'updates/download/';
/**
*
* @var string директория для хранения прошлой версии скрипта
*/
const PATH_BACKUP = 'updates/backup/';
/**
*
* @var string корневая директория установки
*/
private $root_path;
/**
*
* @var int размер загружаемого файла
*/
private $content_length = 0;
/**
*
* @var string хеш загружаемого файла
*/
private $content_md5 = null;
/**
*
* @var int текущий размер загруженного файла
*/
private $current_content_length = 0;
/**
*
* @var string путь до источника обновлений
*/
private $update_uri = null;
/**
*
* @var resource
*/
private $download_stream = null;
/**
*
* @param $root_path
* @return Update
*/
function __construct($root_path = null)
{
if(!isset(self::$root_path)){
$this->root_path = self::formatPath($root_path?$root_path:dirname(__FILE__)).'/';
}
}
/**
* Основной метод загрузки и установки обновления
*
* @throws Exception
* @param $update_uri string URI источника обновлений
* @param $target string путь относительно корня установки к обновляемому файлу
* @return array()
*/
public function execute($update_uri,$target)
{
try{
$this->update_uri = $update_uri;
$target = self::formatPath($target).'/';
$target = preg_replace('@(^|/)\.\./@','/',$target);
$env = self::onBeforeUpdate();
$this->cleanupPath(self::PATH_UPDATE);
$download_file = $this->download();
$this->replace($download_file,$target);
$this->cleanupPath(self::PATH_UPDATE);
self::onAfterUpdate($env);
}catch(Exception $ex){
//В случае ошибки возвращаем переменные окружения в исходное состояние
self::onAfterUpdate($env);
//и очищаем директорию от временных файлов
$this->cleanupPath(self::PATH_UPDATE,true);
throw $ex;
}
}
/**
* Подготавливаем окружение к обновлению
* @return array()
*/
private static function onBeforeUpdate()
{
$env = array();
$env['session_id'] = session_id();
if($env['session_id']){
//KNOWHOW - на длительных операциях открытая сессия блокирует другие скрипты (с одинаковым идентификатором сессии),
// вынуждая их ожидать завершения текущего процесса
session_write_close();
}
//KNOWHOW не даем завершаться скрипту даже если браузер закрыл соединение
ignore_user_abort(true);
return $env;
}
/**
* Возвращаем окружение в исходное состояние
* @param $env array
* @return void
*/
private static function onAfterUpdate($env)
{
if($env['session_id']){
session_start();
}
}
/**
* Загрузка файла с удаленного сервера.
* Определяет подходящий метод загрузки в зависимости от серверного окружения.
*
* @throws Exception
* @return string downloaded file_path
*/
private function download()
{
try{
$name = basename(preg_replace('/(\?.*)/','',$this->update_uri));
$download_file = self::formatPath(self::PATH_UPDATE.'/'.$name);
$this->download_stream=$this->fopen($download_file,'wb');
if(!$this->download_stream){
throw new Exception("Не могу создать временный файл {$download_file}");
}
if($this->curlAvailable()){
//при доступности cURL используем его, так как метод более гибкий
$this->downloadCurl();
}elseif($this->fopenAvailable()){
//иначе, если allow_fopen_url = On, пробуем получить обновление через fopen()
$this->downloadFopen();
}else{
throw new Exception('Нет подходящего транспорта для установки обновления (не поддерживаются ни cURL, ни allow_fopen_url)');
}
if($this->download_stream && is_resource($this->download_stream)){
fclose($this->download_stream);
}
//проверяем длину ответа от сервера - она может не совпадать с заявленной
// - в большую сторону в случае допвывода со стороны сервера ошибок и т.п.
// - в меньшую в случае обрыва соединения
if($this->content_length && ($real_content_length = filesize($this->root_path.$download_file)) && ($this->content_length != $real_content_length)){
throw new Exception(sprintf("Неверный размер файла. Ожидали %d, а получили %d байт.",$this->content_length,$real_content_length));
}
//проверяем md5 хеш загруженного файла
if($this->content_md5 && ($md5 = md5_file($this->root_path.$download_file)) && (strcasecmp ($this->content_md5,$md5)!=0)){
throw new Exception(sprintf("Неверная md5-хеш файла. Ожидали %s, а получили %s",$this->content_md5,$md5));
}
return $download_file;
}catch(Exception $ex){
if($this->download_stream && is_resource($this->download_stream)){
fclose($this->download_stream);
}
if($download_file && file_exists($this->root_path.$download_file)){
@unlink($this->root_path.$download_file);
}
throw $ex;
}
}
/**
* Загрузка файла с удаленного сервера через fopen()
* Для работы необходимо, чтобы в настройках PHP было allow_fopen_url = On
*
* @return void
*/
private function downloadFopen()
{
$source_stream = null;
try{
//по умолчанию таймаут на открытие ресурсов составляет 30 - это слишком много, чтобы узнать, что сети нет, поэтому ставим меньший таймаут
$default_socket_timeout = ini_set('default_socket_timeout', self::TIMEOUT_SOCKET);
$source_stream = fopen($this->update_uri, 'r');
ini_set('default_socket_timeout', $default_socket_timeout);
if(!$source_stream){
throw new Exception("Ошибка подключения к источнику обновлений [{$this->update_uri}].");
}
$this->getStreamInfo($source_stream);
$retry_counter = 0;
while(
($delta=stream_copy_to_stream($source_stream,$this->download_stream,102400))
//бывает, что последние байты ответа сервер отдает очень неохотно - nginx и т.п.
||( $this->content_length && ($this->current_content_length<$this->content_length) && (++$retry_counter<20) )
||( !$this->content_length && (++$retry_counter<3) )
){
if($delta){
$this->current_content_length += $delta;
$retry_counter = 0;
}else{
sleep(3);
}
}
fclose($source_stream);
}catch(Exception $ex){
if($source_stream && is_resource($source_stream)){
fclose($source_stream);
}
throw $ex;
}
}
/**
* Загрузка файла с удаленного сервера с помощью cURL
*
* @throws Exception
* @return void
*/
private function downloadCurl()
{
try{
$ch = null;
if (!($ch = curl_init()) ){
throw new Exception('err_curlinit');
}
if ( curl_errno($ch) != 0 ){
throw new Exception('err_curlinit'.curl_errno($ch).' '.curl_error($ch));
}
$curl_options = array(
CURLOPT_HEADER => 0,
CURLOPT_RETURNTRANSFER => 1,
CURLOPT_TIMEOUT => self::TIMEOUT_SOCKET*60,
CURLOPT_CONNECTTIMEOUT => self::TIMEOUT_SOCKET,
CURLE_OPERATION_TIMEOUTED => self::TIMEOUT_SOCKET*60,
CURLOPT_BINARYTRANSFER => true,
//KNOWHOW переопределенная функция записи позволяет дополнительно фиксировать информацию о переданном размере
CURLOPT_WRITEFUNCTION => array(&$this,'curlWriteHandler'),
//KNOWHOW добавляем хук для чтения заголовков, чтобы узнать размер передаваемого файла и его md5 хеш
CURLOPT_HEADERFUNCTION => array(&$this,'curlHeaderHandler'),
CURLOPT_URL => $this->update_uri,
//TODO на ряде хостингов curl работает только через прокси, который необходимо указать в настройках
);
foreach($curl_options as $param=>$option){
curl_setopt($ch, $param, $option);
}
$res = curl_exec($ch);
if ($errno = curl_errno($ch)) {
$message = "Curl error: {$errno}# ".curl_error($ch)." at [{$this->update_uri}]";
throw new Exception($message);
}
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if($status != 200){
throw new Exception("Неверный ответ сервера {$this->update_uri}",$status);
}
curl_close($ch);
}catch(Exception $ex) {
if($ch){
curl_close($ch);
}
throw $ex;
}
}
/**
* обработчик чтения заголовков для curl
*
* @param $ch
* @param $header
* @return int
*/
private function curlHeaderHandler($ch,$header)
{
$header_matches = null;
if(preg_match('/content-length:\s*(\d+)/i',$header,$header_matches)) {
$this->content_length = intval($header_matches[1]);
}elseif(preg_match('/content-md5:\s*([\da-f]{32})/i',$header,$header_matches)){
$this->content_md5=$header_matches[1];
}
return strlen($header);
}
/**
* обработчик записи в файл для curl
*
* @param $ch
* @param $chunk
* @return int
*/
private function curlWriteHandler($ch,$chunk)
{
$size = 0;
if($this->download_stream&&is_resource($this->download_stream)) {
$size = fwrite($this->download_stream,$chunk);
$this->current_content_length += $size;
}else {
throw new Exception('Ошибка сохранения файла на сервере');
}
return $size;
}
/**
* Replace current version by merged old and updated files
* @param $source_path string
* @param $target_path string
* @return string backup directory paty
*/
private function replace($source_path,$target_path)
{
$target_path = self::formatPath($target_path);
$source_path = self::formatPath($source_path);
$backup_path = false;
if(file_exists($this->root_path.$target_path)){
$backup_path = self::PATH_BACKUP;
$backup_path = self::formatPath($backup_path);
$this->cleanupPath($backup_path);
$this->mkdir($backup_path);
$backup_path .= '/'.basename($target_path);
}
if($backup_path){
if(!$this->rename($target_path,$backup_path)){
throw new Exception("Ошибка создания бекапа {$target_path} в папке {$backup_path}");
}
}
if(!$this->rename($source_path,$target_path)){
//rollback rename
if($backup_path){
$this->rename($backup_path,$target_path);
}
throw new Exception("Ошибка обновления {$target_path} в {$source_path}");
}
return $backup_path;
}
/**
* "Настойчивое" переименование
*
* @param $oldname string path
* @param $newname string path
* @return boolean
*/
private function rename($oldname,$newname)
{
$result = false;
if(@rename($this->root_path.$oldname,$this->root_path.$newname)
||sleep(3)
||@rename($this->root_path.$oldname,$this->root_path.$newname)){
$result = true;
}
return $result;
}
/**
* Очистка директории от файлов
*
* @param $paths string|array
* @param $skip_directory можно оставлять неудаляемые директории
* @return void
*/
private function cleanupPath($paths,$skip_directory = false)
{
foreach((array)$paths as $path){
try{
if(file_exists($this->root_path.$path)){
$dir=opendir($this->root_path.$path);
while (false!==($current_path=readdir($dir))){
if(($current_path != '.' )&&($current_path != '..')){
if(is_dir($this->root_path.$path.'/'.$current_path)){
$this->cleanupPath($path.'/'.$current_path,$skip_directory);
}else{
if(!@unlink($this->root_path.$path.'/'.$current_path)){
throw new Exception("Не могу удалить файл {$path}/{$current_path}");
}
}
}
}
closedir($dir);
if(!@rmdir($this->root_path.$path)&&!$skip_directory){
throw new Exception("Не могу удалить директорию {$path}");
}
}
}catch(Exception $ex){
if($dir&&is_resource($dir)){
closedir($dir);
}
throw $ex;
}
}
}
/**
* Приводим пути к nix виду
*
* windows поймет и такие, а в случае использования правил постобработки с использованием регулярных выражения последние упрощаются
* @param $path string
* @return string
*/
private static function formatPath($path)
{
$path = preg_replace('@([/\\\\]+)@','/',$path);
return preg_replace('@/$@','',$path);
}
/**
* Создание директорий с дополнительными проверками на права записи
*
* @param $target_path
* @param $mode
* @return void
*/
private function mkdir($target_path,$mode = 0777)
{
if(!file_exists($this->root_path.$target_path)){
if(!mkdir($this->root_path.$target_path,$mode&0777,true)){
throw new Exception("не могу создать директорию {$target_path}");
}
}elseif(!is_dir($this->root_path.$target_path)){
throw new Exception("Не могу создать директорию {$target_path}, так как есть файл с таким изменем");
}elseif(!is_writable($this->root_path.$target_path)){
throw new Exception("{$target_path} должна быть доступна по записи. Установите необходимые права доступа.");
}
}
/**
* Проверяем возможность использовать cURL
*
* @return boolean
*/
private function curlAvailable()
{
return extension_loaded('curl') && function_exists('curl_init') && preg_match('/https?:\/\//',$this->update_uri);
}
/**
* Проверяем возможность использовать fopen
*
* @return boolean
*/
private function fopenAvailable()
{
$result = false;
if(stream_is_local($this->update_uri)){
$result = true;
}else{
$scheme = parse_url($this->update_uri,PHP_URL_SCHEME);
if($scheme == 'https'){
$scheme = 'http';
}
$result = ini_get('allow_url_fopen') && in_array($scheme,stream_get_wrappers());
}
return $result;
}
/**
* Читаем метаданные загружаемого файла
*
* @param $source_stream resource
* @param $download_content_length int
* @return void
*/
private function getStreamInfo($source_stream,$download_content_length=4096)
{
$stream_meta_data=stream_get_meta_data($source_stream);
//KNOWHOW без явного чтения потока метаданные потока не всегда доступны
//read data chunk to determine stream meta data
$buf = stream_get_contents($source_stream,$download_content_length);
$this->current_content_length = min($download_content_length,strlen($buf));
$stream_seekable = isset($stream_meta_data['seekable'])?$stream_meta_data['seekable']:false;
$headers = array();
//В зависимости от реализации обертки для http заголовки могут находиться в разных местах
if(isset($stream_meta_data["wrapper_data"]["headers"])){
$headers = $stream_meta_data["wrapper_data"]["headers"];
}elseif(isset($stream_meta_data["wrapper_data"])){
$headers = $stream_meta_data["wrapper_data"];
}
$header_matches = null;
foreach($headers as $header){
//ищем информацию о размере передаваемых данных
if(preg_match('/content-length:\s*(\d+)/i',$header,$header_matches)){
$this->content_length=intval($header_matches[1]);
//и md5 хеше
}elseif(preg_match('/content-md5:\s*([\da-f]{32})/i',$header,$header_matches)){
$this->content_md5=$header_matches[1];
}
}
if($buf && $this->download_stream){
fwrite($this->download_stream,$buf);
}
}
/**
* "Настойчивое" открытие файла
* на случай если ресурс занят другим процессом или директория еще не создана
* @param $filename
* @param $mode
* @param $retry
* @return resource
*/
private function fopen($filename,$mode,$retry = 5)
{
$path = $this->root_path.$filename;
if(!file_exists($path)){
$this->mkdir(dirname($filename));
}
while(!($fp = fopen($path,$mode))){
if(--$retry>0){
sleep(1);
}else{
break;
}
}
return $fp;
}
}
//EOF