Недавно в нашей организации возникла задача предоставления различным пользователям доступа для загрузки объемных видеоматериалов из нашего внутреннего видеоархива. Штатное корпоративное "облако" на базе сервиса 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>