Форма на PHP с безопасной загрузкой файлов на сервер


13-08-2018
Денис Л.
Php
5
505
Форма на PHP с безопасной загрузкой файлов на сервер

Недавно я делал форму обратной связи с возможностью загрузки файлов. При этом стояла задача сделать форму идеально безопасной, с "белым списком" файлов, а также на выходе формировать письмо клиентскому отделу с данными клиента и всеми его файлами. Поскольку файлы (согласно ТЗ) могут иметь большой размер (до 50 МБ каждый), никакой почтовый клиент не выдержит такого объёма информации, особенно учитывая то, что размеры почтовых ящиков у менеджеров всегда переполнены и при этом им никак нельзя пропустить заявку от клиента.

Я принял решение формировать url для ftp-соединения в браузере, с включённым паролем. Для этой цели я создал на VPS отдельного пользователя и ограничил ему доступ только одной папкой, в которой будут документы. Таким образом, сторонние пользователи никак не смогут просмотреть данные файлы, а менеджеры клиентского отдела имеют только на просмотр, причём исключительно в рамках одной папки.

Все основные настройки я вынес в начало скрипта.


Оригинал скрипта Вы можете скачать через bitbucket: https://bitbucket.org/lisogorsky/dev/src/master/uploadsFilesForm/

Итак, как создать безопасную форму на php для загрузки файлов на сервер


Первым делом настроим php.ini по адресу /etc/php.ini

Если мы не сделаем это, то размер принимаемых файлов может быть сильно ограничен. Например, на нашем VPS стояло ограничение в 2 Мб.

post_max_size ставим 50М

upload_max_filesize ставим меньше, 49М

параметр memory_limit должен быть больше, чем post_max_size

Для поля ввода с телефоном можем подключить Jquery maskedinput

Создаём папку в родительском каталоге, например 'clients_documents' (её название потом пропишем в скрипте ниже)


<? header('Content-Type: text/html; charset=utf-8');
/* *** ОСНОВНЫЕ НАСТРОЙКИ *** */

// Сюда вписываем все разрешённые для загрузки расширения файлов:
$goodFiles = ['.doc','.docx','.pdf','.xls','.xlsx','.ppt','.pptx','.zip','.rar','.7z','.jpg','.jpeg','.jpe','.bmp','.png','.txt','.rtf','.gif','.tif','.tiff','.xps','.odt','.ods','.odp','.csv'];

// Здесь указываем на какую почту администратора должно приходить письмо о новой клиентской заявке:
$to = "*******@*******.ru";

// Здесь указываем с какой почты должно приходить письмо:
$from = "mail@*******.ru";

// Сюда вписываем название папки в корне сайта, которую мы предварительно создали для загрузки и хранения документов клиента:
$uploaddir = 'clients_documents';

// Указываем FTP настройки. Они нужны для формирования ссылки, по которой можно будет скачивать документы клиента из браузера.
// В целях безопасности рекомендую создать отдельный ftp-аккаунт, с доступом только к папке с документами.
// Т.е. пользователи смогут посмотреть только папку с пришедшими документами + папку на уровень выше (там где будут присланные документы за другие даты)
// Т.к. скрипт мы будем использовать только внутри Вашей организации и документы будут приходить на указанный Вами е-мейл (например руководителю отдела по работе с клиентами)
// то данная стратегия полностью безопасна.

$ftpHost = '81.177.165.34'; // Указываем реальный FTP хост
$ftpLogin = 'login'; // Указываем реальный FTP логин
$ftpPass = 'password'; // Указываем реальный FTP пароль

// У пользователя стандартный FTP доступ (ко всем папкам на сервере) или Вы настроили клиенту доступ только к директории с документами (исходя из рекомендации выше)? 
// Если вариант 1 - ставим значение true. Если доступ ограничен только текущей папкой - оставляем false

$ftpAccess = false;

// Задаём переменные, полученные из $_POST. Для безопасности обрезаем все теги, которые можем ввести пользователь
if($_SERVER['REQUEST_METHOD'] === 'POST') {
	$today     = date('d-m-Y_H-i-s');
	$problem   = strip_tags(trim($_POST["problem"]));
	$object    = strip_tags(trim($_POST["object"]));
	$documents = strip_tags(trim($_POST["documents"]));
	$dop_info  = strip_tags(trim($_POST["dop_info"]));
	$questions = strip_tags(trim($_POST["questions"]));
	$name      = strip_tags(trim($_POST["name"]));
	$tel       = strip_tags($_POST["tel"]);
	$email 	   = strip_tags($_POST["email"]);
	$ooo       = strip_tags(trim($_POST["ooo"]));
	$files     = $_FILES;
	$subject   = "У Вас новый запрос на обратный звонок c сайта ****.ru"; // здесь указываем нужную нам тему письма
	$message   = "<h3>Заявка с расширенной формы обратной связи</h3><hr>"."\n"; // здесь указываем заголовок письма

	// Все поля ниже заполнены из конкретного примера имеющейся ниже формы. Если у Вас будут другие поля, то меняем ниже название переменных
	// Значения переменных в $_POST['...'] должны соответствовать значениям атрибута name в полях ввода (! это важно !)
	if(!empty($_POST["problem"])) $message .= "<b>Проблема и цель привлечения экспертной организации:</b><br>{$problem}<hr>"."\n";
	if(!empty($_POST["object"]))  $message .= "<b>Объект экспертизы:</b><br>{$object}<hr>"."\n";
	if(!empty($_POST["documents"])) $message .= "<b>Имеющаяся документация по объекту:</b><br>{$documents}<hr>"."\n";
	if(!empty($_POST["dop_info"]))  $message .= "<b>Дополнительная информация:</b><br>{$dop_info}<hr>"."\n";
	if(!empty($_POST["questions"])) $message .= "<b>Задачи (вопросы) для экспертов:</b><br>{$questions}<hr>"."\n";
	if(!empty($_POST["ooo"])) $message .= "<b>Название организации:</b><br>{$ooo}<hr>"."\n";
	
	$message .= "<b>Контактное лицо:</b><br>{$name}<hr>"."\n"."<b>Телефон:</b><br>{$tel}<hr>"."\n"."<b>E-mail:</b><br>{$email}<hr>"."\n";

	$extensionArr = []; $filesName = [];

	// Ниже указывается путь к папке, которую мы задавали выше в переменной $uploaddir
	// В данном примере, папка расположения данного скрипта и папка 'clients_documents' находятся на одном уровне, в корне сайта, т.е. эти папки находятся на одном уровне.
	// Если Вы хотите использовать файл с формой как отдельный файл (без папки) и он будет находиться на одном уровне с папкой 'clients_documents',
	// то тогда строка ниже будет такой: $uploaddir = '/'.$uploaddir.'/'.$today;
	$uploaddir = '../'.$uploaddir.'/'.$today;

	// В следующих строчках мы проверяем, загружены ли по факту пользователем файлы.
	foreach($files as $file) {if(!empty($file['name'])) {array_push($filesName, $file['name']);}}
	count($filesName)>0 ? $resultUpload = true : $resultUpload = false;

	// Функция для транслитерации кириллических символов, пробелов и прочего в названии загружаемых файлов
	function transl($st,$code='utf-8') {
		$st = mb_strtolower($st, $code);
		$st = str_replace(array(
			'?','!',',',':',';','*','(',')','{','}','%','#','№','@','$','^','-','+','/','\\','=','|','"','\'','&','а','б','в','г','д','е','ё','з','и','й','к','л','м','н','о','п','р','с','т','у','ф','х','ъ','ы','э',' ','ж','ц','ч','ш','щ','ь','ю','я'
		), array(
			'','','','','','','','','','','','','','','','','','','','','','','','','','a','b','v','g','d','e','e','z','i','y','k','l','m','n','o','p','r','s','t','u','f','h','j','i','e','_','zh','ts','ch','sh','shch','','yu','ya'  
		), $st);
		return $st;
	}

	// Если на каком-то этапе понадобится отладка - раскомментрируйте строку ниже
	// echo "<pre>"; print_r($files); echo "</pre>";

	if($resultUpload) { // Если файлы были загружены
		if(!is_dir($uploaddir)) mkdir($uploaddir, 0755, true); // проверяем, была ли создана директория для загрузки файлов
		foreach($files as $file) { // перебираем массив с файлами
			if(!empty($file['name'])) {
				// Проверяем каждый файл на соответствие "хорошим" расширениям, которые мы задавали в настройках выше
				if(in_array(strrchr(mb_strtolower($file['name']),'.'), $goodFiles)) { 
					// Перемещаем файлы из временной папки сервера, одновременно производим транслитерацию
					if(move_uploaded_file($file['tmp_name'], $uploaddir.'/'.transl($file['name']))) { 
						$files[] = realpath($uploaddir.$file['name']);
						// для безопасности сразу всем файлам делаем права только на чтение
						chmod($uploaddir.'/'.transl($file['name']), 0444); 
					}
				}
				else { // Если была попытка загрузки файла с "плохим" расширением
					$loadError = true;
					$extension = strrchr($file['name'],'.'); 
					array_push($extensionArr, $file['name']); // формируем массив с "плохими" расширениями
					echo "<style>b{font-weight:bold!important}</style>"; // выводим сообщение пользователю
					echo "<b>{$file['name']}</b> - этот файл не загружен, т.к. запрещено загружать файлы с расширением <b>{$extension}</b><br>";
				}
			}
		}
		if($loadError) {
			echo "<br>Все остальные файлы успешно загружены!";
			$badExtensions = implode("\n", $extensionArr);
			$htmlStart = '<!DOCTYPE html><html lang="ru"><head><meta charset="utf-8"><body><pre><h2>';
			$htmlEnd = '</h2></pre></body></html>';
			// формируем файл в папке с загруженными файлами, что была попытка загрузки файлов с "плохими" расширениями и указываем какие конкретно это файлы
			FILE_put_contents($uploaddir.'/info.txt', $htmlStart."Клиент пытался загрузить запрещённые файлы:\n{$badExtensions}".$htmlEnd, FILE_APPEND);
		}
		// ниже формируем строку с ссылкой для браузера, чтобы можно было посмотреть и скачать файлы клиента
		if(!$ftpAccess) {
			$message .= "<b>Путь на файлы, отправленные клиентом:</b><br>"."ftp://{$ftpLogin}:{$ftpPass}@{$ftpHost}".'/'.$today."/<hr>"."\n";
		}
		else {
			$message .= "<b>Путь на файлы, отправленные клиентом:</b><br>"."ftp://{$ftpLogin}:{$ftpPass}@{$ftpHost}".strstr($uploaddir,'/')."/<hr>"."\n";
		}
	}

	$subject  = "=?utf-8?B?".base64_encode($subject)."?=";
	$headers  = "From: $from\r\nReply-to: $from\r\nContent-type: text/html; charset=utf-8\r\n";

	// Ниже формируем логи, в которые пишем всю информацию, введённую клиентом, а также информацию о попытке загрузки "плохих" файлов
	$logText = strip_tags($message);
	$logFile = strstr($uploaddir, '/'.$today, true)."/mail.log";
	FILE_put_contents($logFile, "\n{$today}\n{$logText}\n", FILE_APPEND);
	if($loadError) FILE_put_contents($logFile, "{$today} была попытка загрузки запрещённых файлов:\n{$badExtensions}\n", FILE_APPEND);
	chmod($logFile, 0600);
	
	if(mail($to, $subject, $message, $headers)) { // отправляем письмо администратору с данными из формы, заполненной клиентом
		$subj  = "Ваша заявка принята в работу в ****************"; // вместо звёздочек ставим название организации
		$subj  = "=?utf-8?B?".base64_encode($subj)."?=";
		$mess  = "{$name}, Ваша заявка принята в работу в ******************.\n<br>Ожидайте звонок на номер телефона {$tel} в течении 8 рабочих часов.\n\n<br><br>С уважением,\n<br>НАЗВАНИЕ КОМПАНИИ\n<br>+7 (***) ***-**-**\n<br>+7 (***) ***-**-**\n<br>https://www.*******.ru";
		mail($email, $subj, $mess, $headers); // дополнительно отправляем письмо клиенту о том, что его заявка принята в работу
		echo "<div class='general_form_sended'><b>{$name}</b>, Ваша заявка принята в работу в **************.<br>Ожидайте звонок на номер телефона {$tel} в течении 8 рабочих часов. Письмо с нашими контактными данными направлено на Ваш e-mail {$email}</div>";
		echo "<form class='hidden'>";
	}
}
else {
	echo "<form action='#' class='general_form' method='POST' enctype='multipart/form-data'>";
}
?>
	<!-- Все поля ввода, приведённые ниже, указаны в качестве примера -->
	<div>
		<p>Кратко опишите проблему и цель привлечения экспертной организации</p>
		<textarea rows="5" name="problem"></textarea>
	</div>
	<div>
		<p>Кратко опишите объект (местонахождение, площадь, объем, этажность, протяженность, проект, раздел проекта, отдельная конструкция, строительные материалы, сметная документация, исходно-разрешительная документация, бизнес-планы, нематериальные активы и т.п.)</p>
		<textarea rows="5" name="object"></textarea>
	</div>
	<div>
		<p>Перечислите имеющуюся документацию по объекту (проектно-сметная документация, технологический паспорт, договоры, приложения, планы и т.п.)</p>
		<textarea rows="5" name="documents"></textarea>
	</div>
	<div>
		<p>Если у Вас имеется дополнительная информация, в том числе и фотографии, укажите её здесь</p>
		<textarea rows="5" name="dop_info"></textarea>
	</div>
	<div>
		<p>Сформулируйте задачи (вопросы), которые Вы хотите поставить на разрешение экспертов</p>
		<textarea rows="5" name="questions"></textarea>
	</div>
	<div>
		<p>Прикрепите файлы (Вы можете прикрепить к сообщению до 10-ти файлов (включительно))</p>
		<p>Разрешённые типы файлов для загрузки: <?=implode(", ",$goodFiles)?></p>
		<br>
		<input type="file" name="file0"><input type="file" name="file1">
		<input type="file" name="file2"><input type="file" name="file3">
		<input type="file" name="file4"><input type="file" name="file5">
		<input type="file" name="file6"><input type="file" name="file7">
		<input type="file" name="file8"><input type="file" name="file9">
	</div>
	<fieldset>
		<legend>Пожалуйста, уточните контактную информацию для связи с Вами</legend>
		<div>
			<p>Контактное лицо *</p>
			<input name="name" required pattern="^[а-яА-ЯёЁ\s]+$">
		</div>
		<div>
			<p>Телефон *</p>
			<input name="tel" type="tel" required>
		</div>
		<div>
			<p>E-mail *</p>
			<input name="email" type="email" required>
		</div>
		<div>
			<p>Название организации</p>
			<input name="ooo">
		</div>
	</fieldset>
	<button type="submit">Отправить заявку</button>
</form>

?>


СТИЛИ:

.general_form, .general_form * {-webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box;}
.general_form {margin-top: 20px; margin-bottom: 30px;}
.general_form legend, .general_form fieldset {text-align: center;}
.general_form fieldset {margin-top: 40px; background: rgba(17, 61, 124, .05);}
.general_form fieldset > div {width: 90%; margin-left: 5%; margin-bottom: 15px;}
.general_form fieldset > div:last-child {margin-bottom: 20px;}
.general_form p {margin-bottom: 3px;}
.general_form fieldset p {margin-bottom: 0;}
.general_form input {line-height: 25px; padding-top: 2px !important; padding-bottom: 2px !important;}
.general_form textarea, .general_form input:not([type="file"]) {width: 100%; border-radius: 4px; border: 1px solid rgba(17, 61, 124, 0.5); box-shadow: 0 0 1px #113d7c; padding: 10px;}
.general_form input[type="file"] {display: inline-block; width: 50%; float: left;}
.general_form > div {margin-bottom: 20px; overflow: hidden;}
.general_form button {display: block; margin: 25px auto 35px; padding: 10px 30px; cursor: pointer; background: #113D7C; color: #fff; border: 3px solid #fff; border-radius: 5px; font-size: 18px; transition: box-shadow .25s;}
.general_form button:hover {box-shadow: 0 0 2px 2px #113d7c;}
.general_form_sended {margin-top: 20px; margin-bottom: 30px; padding: 20px; border-radius: 5px; border: 2px solid green; font-size: 17px; line-height: 1.35;}
.general_form_sended b {font-weight: bold !important;}
@media(max-width: 767px) {.general_form input[type="file"] {width: 100%; margin-bottom: 10px;}}


СКРИПТЫ:

Они нужны только для эффектов и отправки события в Метрику. Вы можете не использовать их.

Если всё же захотите использовать, то на Вашем сайте должна стоять библиотека jQuery.

$('form.general_form').on('submit', function() {yaCounter********.reachGoal('general_form');}); // здесь прописываем номер счётчика на Яндекс.Метрике. Если не требуется - удаляем эту строчку полностью
$('form.general_form textarea, form.general_form input').on('focus', function() {
	$('form.general_form p').css({'font-weight':'normal', 'color':'#000', 'letter-spacing': 'normal'});
	$(this).prev('p').css({'font-weight':'bold', 'color':'#113D7C', 'letter-spacing': '-.55px'});
	if(this.tagName=='TEXTAREA'){$('form.general_form textarea').css('background','white');$(this).css('background','aliceblue')};
})

Подписывайтесь на группу в ВКонтакте, чтобы всегда быть в курсе актуальных выпусков Web development blog!

Читайте также:

PHP скрипт, определяющий сколько лет организации

Ставим надёжную защиту от спама на сайте своими силами