Простое решение для раздачи файлового архива через временные WEB-ссылки

Недавно в нашей организации возникла задача предоставления различным пользователям доступа для загрузки объемных видеоматериалов из нашего внутреннего видеоархива. Штатное корпоративное "облако" на базе сервиса SeaFile, как оказалось не вполне подходит для работы с файлами такого размера (в несколько десятков гигабайт).

Короче возникла острая потребность в простом и неприхотливом решении для раздачи содержимого директории файлового сервера через индивидуальные временные Веб-ссылки. Поначалу я был в полной уверенности, что в течение пары часов найду в сети что-то, что можно легко прикрутить для своих нужд. Однако результат меня слегка разочаровал: попадались либо монстры типа OpenMediaVault, либо приложения для десктопов на базе Windows. Пара решений которые более-менее подходили имели проблемы с русской кодировкой, что в эпоху utf8 выглядит немного странно.

С навязчивым ощущением изобретателя велосипеда пришлось вспоминать приемы Веб-программирования, которым я по хорошему уже лет десять как не занимался. Полноценная система мне была не нужна, скорее хотелось сделать такую вот web-утилитку, которую можно быстро прикрутить по месту куда угодно.

Если говорить тезисно, то решение получилось следующим:

  1. Отказ от реестра в пользу подписанных ссылок. Сервер формирует ссылку куда упаковывает путь к файлу загрузки, временную метку и подписывает все это закрытым ключом. Это позволяет существенно упростить всю системы в целом.

  2. При загрузке файла все эти параметры извлекаются из URL и предварительно "валидируются".

  3. Вся система представлена одним единственным скриптом index.php, который доступен по разным URL (/admin и /download).

  4. Если доступ происходит по пути /admin , то скрипт работает в режиме "админки", он показывает файловый каталог и временные ссылки, которые можно выслать пользователю. В остальных случаях скрипт работает в режиме загрузки файлов.

  5. Путь URL /admin необходимо надлежащим образом защитить средствами http-сервера.

В данной реализации все настроечные параметры хранятся с скрипте, какие-либо дополнительный файлы конфигураций отсутствуют. Это сделано опять же для простоты развертывания следуя концепции простой веб-утилитки. Хотя если образование вам не позволяет принять такой подход, можете переписать код чтобы все было "правильно". Правда и смысл подписанных ссылок тогда тоже частично теряется. В такую систему уже можно добавить реестр и сделать ссылки короткими.

В заключение привожу код скрипта, пример его развертывания на базе http-сервера Apache, и пару скриншотов. Надеюсь представленное решение будет полезно "профильной аудитории":

NameVirtualHost *:80
<VirtualHost *:80>
    ServerName  download.myconpany.ru
    AddHandler php5-script .php
		AddType text/html .php
		DirectoryIndex index.php    
    # place download script here:  /var/www/download
    Alias 						/admin 		/var/www/download
    Alias 						/download /var/www/download
		<LocationMatch /admin >
        AuthType Basic
        AuthName "Restricted Content"
        AuthUserFile /var/pwdb/.htpasswd
        Require valid-user
        Order Allow,Deny
        Allow from 10.0.0.0/8
    </LocationMatch>
</VirtualHost>
<?php
class MyErrorException extends Exception { }
class MyBreakException extends Exception { }

///////////////////////////////////////////////////////////////////////
// Настроечные параметры //////////////////////////////////////////////
// ////////////////////////////////////////////////////////////////////
$EXP_DAYS = 3;                  // время жизни ссылкок загрузки в днях
$TOPDIR = "/data/download";     // расположение файлового архива
$KEY=('7lkh4kwsl0451382');      // ключ подписи ссылок загрузки (поменять!)

$curdir = $TOPDIR;
$BODY = "";
$HEAD = "Каталог загрузки";

$uri_parts = explode('?', $_SERVER['REQUEST_URI'], 2);
$access = end(explode('/', trim($uri_parts[0],'/')));

try {
    if ( $access != "admin" ) {
        $HEAD = "Загрузка файла";
        list ($p,$t) = my_path_decode($_GET['download']);		
        
        if (abs(time()-$t) > $EXP_DAYS * 24 * 3600) {
            throw new MyErrorException("ссылка устарела");
        }	
        $f = basename($p);
        $file = secure_path("$TOPDIR/$p");

        if ($file == false) {
            throw new MyErrorException("плохой путь");
        }
        $size = 0;	
        if (!file_exists($file)) {
            throw new MyErrorException("файл $f не найден");
        }
        $size = filesize($file);
        
        if(isset($_GET['start'])) {
                
                set_time_limit(3600);
                $chunksize = 5 * (1024 * 1024); //5 MB (= 5 242 880 bytes) per one chunk of file.
                $handle = fopen($file, 'rb');

                if ($handle == false ) {
                    throw new MyErrorException("не могу прочитать файл");
                }                
                header('Content-Description: File Transfer');
                header('Content-Type: application/octet-stream');
                header('Content-Disposition: attachment; filename="'.basename($file).'"');
                header('Expires: 0');
                header('Cache-Control: must-revalidate');
                header('Pragma: public');
                header('Content-Length: ' . $size);

                while (!feof($handle))
                { 
                  print(@fread($handle, $chunksize));
                  ob_flush();
                  flush();
                } 
                fclose($handle); 
                exit;
        }
        
        $qs = $_SERVER['QUERY_STRING'];
        $size = custom_number_format($size);
        $BODY .= "$f ($size) <BR><A href='?$qs&start=on'>[Загрузить файл]</A><A title='Скопировать ссылку в буфер обмена' href='#' onclick='copyURL()'>[Скопировать ссылку]</A><BR>\n";
        
        $BODY .= "<BR><i>Сылка действует до " . date("d.m.Y H:i",  $t+24*3600*$EXP_DAYS) . " </i>";
        //goto END_OF_HEAD;
        throw new MyBreakException();
    }

    $path = trim(isset($_GET['path']) ? $_GET['path'] : "" ,'/');

    if ( $access != "admin" ) {
        throw new MyErrorException("Нарушение прав доступа !!!");
    }
    if ($path) {
        $curdir = secure_path($TOPDIR."/".$path);
        if ($curdir == false) {
            throw new MyErrorException("Плохой путь!");
        }
    }
    if (is_dir($curdir) and $dh = opendir($curdir)) {
        $dirs = array();
        $files = array();
        while (($file = readdir($dh)) !== false) {
            if ($file == "." or $curdir == $TOPDIR and $file == "..") {
                continue;
            } 
            elseif (is_dir("$curdir/$file")) {
                    array_push($dirs,$file);
            }
            elseif (is_file("$curdir/$file")) {
                    array_push($files,$file);
            }
        }
        closedir($dh);
        
        asort($dirs);asort($files);
        $BODY .= "<TABLE>\n";
        
        if ($curdir != $TOPDIR) {
            $BODY .= "<TR><TD>[TOP]<TD> <A href='?'>TOP <BR><A><TD></TR>\n";
        }
        foreach ($dirs as $dir) {
            if ($dir == "..") {
                $p = trim(preg_replace("/\/?[^\/]*$/", "", $path),'/');
            }
            else {
                $p = trim("$path/$dir",'/');
            }
            $p = urlencode($p);
            $BODY .= "<TR><TD>[DIR]<TD><A href='?path=$p'>$dir</A><TD></TR>\n";
        }
        foreach ($files as $file) {
            $s = filesize("$TOPDIR/$path/$file");
            $s = custom_number_format($s);            
            $d = urlencode(my_path_encode(trim("$path/$file",'/')));
            $BODY .= "<TR><TD>[FILE]<TD>$file ($s)<TD><A href='../download?download=$d'>[Временная ссылка]</A>\n";
        }
        $BODY .= "</TABLE>\n";        
    }
    else {
        throw new MyErrorException("Невозможно отрыть директорию для текщего пути $path");
    }    
}
catch (MyBreakException $e) {

}
catch (MyErrorException $e) {
	$BODY =  err_msg($e->getMessage());
}
function err_msg($msg) {
	return "<FONT color=red> ОШИБКА: $msg</FONT><BR>\n";
}
function custom_number_format($n, $precision = 2) {
    if ($n < 1024) {
        $n_format = number_format($n) . 'B';
    } else if ($n < 1024*1024) {
        $n_format = number_format($n / 1024, $precision) . 'K';
    } else if ($n < 1024*1024*1024) {
        $n_format = number_format($n / (1024*1024), $precision) . 'M';
    } else {
        // At least a billion
        $n_format = number_format($n / (1024*1024*1024), $precision) . 'G';
    }
    return $n_format;
}

function secure_path ($path) {
	global $TOPDIR;
	$p = realpath($path);
	if (startsWith($p,$TOPDIR)) {
		return $p;		
	}
	return false;
}

function startsWith ($string, $startString) 
{ 
    $len = strlen($startString); 
    return (substr($string, 0, $len) === $startString); 
}

function my_path_encode ($path) {
	global $KEY;
	$body = json_encode(array($path,time()));
	$hash = md5($body.$KEY);
	$out = json_encode(array($hash,$body));
	return base64_encode($out);
}
function my_path_decode ($epath) {
	global $KEY;
	list ($hash,$body) = json_decode(base64_decode($epath));
	if ( $hash !== md5($body.$KEY) ) {
		throw new MyErrorException("ссылка не существует");
	}
	list($path,$time) = json_decode($body);
	return array($path,$time);
}

?>

<html>
<head>
<script language="JavaScript">
function copyURL () {
	var dummy = document.createElement('input'), text = window.location.href;

	document.body.appendChild(dummy);
	dummy.value = text;
	dummy.select();
	document.execCommand('copy');
	document.body.removeChild(dummy);
	alert('Ссылка с копирована в буфер обмена');
}

</script>
</head>
<body>
<H1> Файловый архив</H1>
<H2> == <?php echo $HEAD; ?> ==</H2>
<HR>
<?php echo $BODY; ?>
<HR>
== Здесь будет ваша подпись ==
</body>
</html>
Tags:
PHP, утилиты, веб-сайт

You can't comment this post because its author is not yet a full member of the community. You will be able to contact the author only after he or she has been invited by someone in the community. Until then, author's username will be hidden by an alias.