منبع اصلی نوشتار زیر در این لینک قرار دارد

دانلود فایل با قابلیت Resume در PHP

کد انتهایی یه مشکل کوچک داشت که اصلاح شد :)‌
ایندفعه،‌میخوام یه چیزی بنویسم درباره دانلود (آخرش هم نفهمیدم که این کلمه رو به فارسی چی بگیم!! بارگزاری،‌بارگیری، گرفتن ..)‌ خیلی وقتها میخوایم که یه فایل رو محافظت کنیم که همینجوری دانلود نشه، یعنی حتما اسم کاربری بخواد، یا اینکه مطمئن شیم که فایل حتما از تو صفحه خودمون دانلود میشه که آمار درست باشه.
یه راه ساده وجود داره، که بیشتر از اون استفاده میشه،‌اونهم به سادگی گذاشتن فایل در یک پوشه خارج از دسترسی مستقیم (مثلا خارج از ریشه وب سرور، یا محافظت شده توسط وب سرور،‌مثلا آپاچی با کمک فایل .htaccess ) و بعد به پس دادن فایل از طریق کد به کاربر. مثلا برای PHP میتونید همچین کدی بنویسید :

//Before this point you should check everything
//include user authenticate and any thing else
$result=@readfile(\'/path/to/file\');
if ($result===false)
   //Error :(
else {
   // $result contain byte count
}

این روش بد نیست، کاربر نمیتونه لینک مستقیم بگیره، که خوب این معمولا برای خیلی ها مهمه، خصوصا با این وضع وبلاگها و سایتهای ایرانی که متخصص کپی/پیست و گرفتن لینک و گذاشتن به اسم خودشون حتی بدون زحمت آپلود مجدد هستن.
اما یه عیب بزرگ داره،‌این دانلود دیگه قابلیت resume نداره. منم که دقیقا میتونم کاربر دیال آپی رو درک کنم،‌مخصوصا اینکه تو ایران بزرگ شدم!! خوب این شد که نشستم و بررسی کردم که چطوری این مشکل رو هم حل کنم. البته این کارو خیلی وقت پیش انجام دادم، و چون امروز خواهر زاده ام گیر داده بود، ‌پیداش کردم و یادم افتاد که میشه اینجا بنویسمش،‌و خصوصا اینکه این روزا همش تو فکر اینم که چی بنویسم که ارزش نوشتن داشته باشه .

اول باید یه توضیح ساده بدم. اونم اینکه اصلا این روش resume چطور کار میکنه. واسه اینکار سرور باید یه اطلاعاتی رو به صورت header بفرسته به کلاینت در ازای درخواست کلاینت. این کار رو به راحتی میشه انجام داد :

	header(\'Accept-Ranges: bytes\');

این رو وب سرورها برای هر فایلی که قابلیت resume بخواد داشته باشه میفرستن. البته اگه خود سرور این قابلیت رو داشته باشه. خوب ما هم همینکار رو انجام میدیم یعنی این خط رو به جواب کلاینت اضافه میکنیم، اینجوری حتی اگه خود وب سرور هم این قابلیت رو نداشته باشه ما این قابلیت رو اضافه کردیم (یه بار یه وب سرور مینوشتم، با دلفی و ایندی و خودم این قابلیت رو اونجا اضافه کردم، خیلی سخت نبود :)‌ )
حالا کلاینت وقتی میفهمه که وب سرور این قابلیت رو داره ، علاوه بر آدرس فایل یه سری اطلاعات هم میفرسته.ما کاری با کلاینت نداریم،‌چون تو این حالت کلاینت برنامه دانلود هست، که خوب از بحث ما جداست. این اطلاعات رو میتونید (توی PHP ) از آرایه $_SERVER بخونید. این اطلاعات عبارتند از :

	$ranges= $_SERVER[\'HTTP_RANGE\'];
	//Now ranges contain some thing like : Range: bytes=0-500

البته توی Draft مربوط به این قضیه که من خوندم، که Range میتونه چند تایی باشه که من کاری به اون قضیه ندارم و اصولا هم تا به حال ندیدم تو عمل این چند تایی بودن رو. اما این دو عدد که با یه منها از هم جدا میشن،‌ نشونگر بایتهایی هستند که خواسته شده. علامت منها همیشه باید باشه. اما یکی از دو عدد میتونه نباشه (دو عدد همزمان نمیتونن نباشن) اگه عدد اول نباشه، یعنی یه منها باشه بعد عدد دوم، به معنی درخواست n بایت انتهایی فایل هست، که n میشه همون عدد دوم.
اما اگه عدد دوم نباشه، یعنی یه عدد m بعد یه منها،‌به این معنیه که از بایت m شروع کن تا آخر فایل خواسته شده. یادتون هم باشه که بایتها از صفر شروع میشن، یعنی اولین بایت صفره.
خوب این درخواست که بیاد شما باید پاسخ بدید. پاسخ هم ساده هستش:

	header("HTTP/1.0 206 Partial Content");
	header("Status: 206 Partial Content");
	header(\'Accept-Ranges: bytes\');

دو تای اولی میگن که اطلاعاتی که قراره با این اتصال فرستاده بشن،‌ یه تیکه از فایل هستن نه یه فایل کامل. خط سوم هم که بالاتر گفتم. البته یادتون باشه header های دیگه مثلا اینها هم باید باشن :

	header(\'Content-type: \' . $mime);
	header(\'Content-Disposition: attachment; filename="\' . $filename . \'"\');
	header(\'Last-Modified: \' . date(\'D, d M Y H:i:s \\G\\M\\T\' , $data_modifed));

یا خیلی header های استاندارد دیگه، تو این مثال اولی نوع فایل رو مشخص میکنه مثلا image/jpeg یا application/otect-stream یا … دومی هم اسم واقعی فایل رو میگه خصوصا اینکه ما داریم فایل رو طوری میفرستیم که آدرسش معلوم نشه، ولی بهتره اسم فایل رو معلوم کنیم که نرم افزار کلاینت یعنی همون دانلود منیجر یا بروزر بفهمه که اسم فایل چیه تا از اسم فایل php مثلا download.php استفاده نکنه. سومی هم که زمان آخرین دستکاری فایله، و خوب جز اینها باز هم میتونه باشه یه چک بکنید میتونید همه رو تو یه جستجوی ساده توی وب پیدا کنید.
اما حالا باید به کلاینت بگیم که چه بایتهایی رو داریم میفرستیم و چند بایت داریم میفرستیم ، به سه متغییر استفاده شده دقت کنید تو کامنتهای بالای کد نوشتم که هر کدوم چی هست :

	//$size : size of file or data (all data not this part)
	//$seek_start : start of data in file, for example in ( Range: bytes=0-500 ) $seek_start=0
	//$seek_end : end of data in file, for example in ( Range: bytes=0-500 ) $seek_end=500
	header("Content-Range: bytes $seek_start-$seek_end/$size");
	header("Content-Length: " . ($seek_end - $seek_start + 1));

خوب،‌دیگه header کافیه وقت اطلاعات واقعی هستن که فرستاده بشن. این اطلاعات میتونه هر چی باشه، از دیتابیس باشه، از فایل واقعی باشه یا… من فرض رو بر فایل واقعی میذارم. خوب ما گفتیم که یه قسمت از فایل رو قراره که بفرستیم نه همه اونو،‌پس وقتشه که شروع کنیم، فایل رو باز کنیم، اون قسمت مورد نظر رو بخونیم، و بعد مستقیم توی خروجی بنویسیم، مثلا با یه echo ساده. البته مشکلی به وجود میاد اونم برای فایلهای بزرگ و رنجهای بزرگ. یعنی مثلا یه فایل ۱ گیگا داری، برنامه دانلود میزنه ۴ قسمت ۲۵۰ مگابایتی درخواست میکنه. خوب اینجا مشکله که کل فایلو یه جا بخونی و بریزی بیرون، چون معمولا PHP برای استفاده از حافظه محدودیت داره. برای رفع این مشکل یه راه هست و اونم اینه که فایل رو تکه تکه بخونی مثل این حالت :

	$data_len=$seek_end-$seek_start;
	fseek($file,$seek_start,SEEK_SET);
	$bufsize=2048;

	ignore_user_abort(true);
	@set_time_limit(0);
	while (!(connection_aborted() || connection_status() == 1) && $data_len > 0){
		if ($data_len < bufsize)
			echo fread($file , $data_len);
		else
			echo fread($file , bufsize);
		$data_len -= $bufsize;
		flush();

	}

اون سه تا تابع ignore_user_abort و connection_aborted و connection_status به ما کمک میکنن که کنترل پایان عمل رو از کاربر بگیریم که برای این مورد اینکار بهترین کاره(یعنی اگه کاربر عمل دانلود رو لغو کنه بلافاصله اسکریپت متوقف نمیشه، ادامه پیدا میکنه تا درست و حسابی متوقف بشه. )‌. از طرفی با set_time_limit محدودیت زمان اجرای PHP رو هم از بین میبریم که تو این مورد خیلی مهمه، چون PHP بعد از ۳۰ ثانیه به صورت اتوماتیک متوقف میشه و اگه دانلود زیاد طول بکشه این زیاد جالب نیست. اندازه بافر رو هم ۲۰۴۸ بایت در نظر گرفتم که میشه تغییرش داد.

کد کامل هم میشه این (که البته شما میتونید اونو به صورت یه کلاس دربیارید، من بیشتر قصدم این بود که توضیح بدم همه چیزو نه اینکه یه کلاس کامل بنویسم)

<?php
	date_default_timezone_set(\'GMT\');

	//1- file we want to serve :
	$data_file="/usr/home/f0rud/Desktop/largefile";
	$data_size=filesize($data_file);
	$mime=\'application/otect-stream\'; //Mime type of file. to begin download its better to use this.
	$filename=basename($data_file); //Name of file, no path included

	//2- Check for request, is the client support this method?
	if (isset($_SERVER[\'HTTP_RANGE\']) || isset($HTTP_SERVER_VARS[\'HTTP_RANGE\'])){
		$ranges_str=(isset($_SERVER[\'HTTP_RANGE\']))?$_SERVER[\'HTTP_RANGE\']:$HTTP_SERVER_VARS[\'HTTP_RANGE\'];
		$ranges_arr=explode(\'-\', substr($ranges_str , strlen(\'bytes=\')));
		//Now its time to check the ranges
		$ranges_arr[0]=intval($ranges_arr[0]);
		if ((intval($ranges_arr[0])>=intval($ranges_arr[1]) &&
			$ranges_arr[1]!="" &&
			$ranges_arr[0]!="" ) ||
			($ranges_arr[1]=="" && $ranges_arr[0]=="")){
			//Just serve the file normally request is not valid :(
			$ranges_arr[0]=0;
			$ranges_arr[1]=$data_size;
		}
	} else { //The client dose not request HTTP_RANGE so just use the entire file
		$ranges_arr[0]=0;
		$ranges_arr[1]=$data_size;
	}

	//Now its time to serve file
	$file=fopen($data_file,\'rb\');

	//I use seek and tell to find the location, since I\'m too lazy now
	//You may use some + or - instead of all this :)
	if ($ranges_arr[0]==""){
		//Status 1 : the first one dose not exist
		fseek($file, - intval($ranges_arr[1]),SEEK_END);
		$seek_start=ftell($file);
		fseek($file, intval($ranges_arr[1]),SEEK_CUR);
		$seek_end=ftell($file);
	}elseif ($ranges_arr[1]==""){
		//Status 2 : the last one dose not exist
		fseek($file,intval($ranges_arr[0]),SEEK_SET);
		$seek_start=ftell($file);
		fseek($file, $data_size - intval($ranges_arr[1]),SEEK_CUR);
		$seek_end=ftell($file);
	}else{
		//Status 3 : Both are here :)
		fseek($file,intval($ranges_arr[0]),SEEK_SET);
		$seek_start=ftell($file);
		fseek($file,  intval($ranges_arr[1])-intval($ranges_arr[0]),SEEK_CUR);
		$seek_end=ftell($file);
	}

	//Lets send headers 

	header(\'HTTP/1.0 206 Partial Content\');
	header(\'Status: 206 Partial Content\');
	header(\'Accept-Ranges: bytes\');

	header(\'Content-type: \' . $mime);
	header(\'Content-Disposition: attachment; filename="\' . $filename . \'"\');
	header("Content-Range: bytes $seek_start-$seek_end/$data_size");
	header("Content-Length: " . ($seek_end - $seek_start));

	//Finally serve data and done ~!
	$data_len=$seek_end - $seek_start;
	fseek($file,$seek_start,SEEK_SET);
	$bufsize=2048;

	ignore_user_abort(true);
	@set_time_limit(0);
	while (!(connection_aborted() || connection_status() == 1) && $data_len > 0){
		if ($data_len < $bufsize)
			echo fread($file , $data_len);
		else
			echo fread($file , $bufsize);
		$data_len -= $bufsize;
		flush();

	}

	fclose($file);
?>

من اینو با FDM و DownThemAll تست کردم. اگه کسی با نرم افزار دیگه تست کرد و جواب داد همینجا بگه. یه چیز عجیب اینه که WGET با این کار نمیکنه مدام در مورد Partial Content خطا میده :)‌ به هر حال .
اصلاحیه برای IE8
دوستی توی این کامنت نوشتن که این کد با اینترنت اکسپلورر ۸ مشکل داره و علتش هم این باگ هستش : Cannot Download .pdf File with HTTP 1.1 Cache-Control = “no-cache” Directive

ایشون به عنوان راه حل گفتن که بایستی این دو خط کد هم به Header پاسخ اضافه بشه به عنوان رفع مشکل :‌

header("Cache-Control: no-cache");
header("Pragma: no-cache");

این کد باید بین خطوط ۵۸ تا ۶۰ قرار بگیره. فعلا هنوز امکان امتحانشو ندارم. بعد از بررسی دقیقتر حتما اصلاحش رو خواهم نوشت. فعلا اصلا در شرایط نوشتن کد نیستم. بازم ممنون از این دوست.

پستهای مرتبط :

  1. دانلود از SourceForge بدون مشکل اسکریپت رو اگه نصب کردید دوباره آپدیت کنید، چون sf...
  2. HTTP Redirect یه چند وقتی هست که مدام درگیر کارهای امنیتی، خصوصا...
  3. Mysql Menu قسمت سوم چند وقت پیش در باره منو و طریقه ایجاد آن...



برچسب ها : , , , ,