PHPGangsta - Der praktische PHP Blog

PHP Blog von PHPGangsta


Dateidownload via PHP mit Speedlimit und Resume

with 19 comments

Wenn man statische Dateien zum Download anbieten möchte lässt man das meistens den Webserver erledigen. Dann sind auch häufig Funktionalitäten wie ein Speedlimit oder das Wiederaufnehmen des Downloads (Resume) möglich.

Doch was macht man, wenn etwas mehr Kontrolle nötig ist, beispielsweise eine Authentifizierung in PHP, die Datei erst in Echtzeit berechnet werden muss oder die Datei aus einer Datenbank kommt (böse!). Oder man hat keinen Zugriff auf die Konfiguration des Webservers. Dann kommt man nicht um ein kleines PHP-Script drumherum, muss sich dann aber um die Speedlimit-/Resume Funktionalitäten selbst kümmern. Beispielsweise kann man Premium-Usern mehr Downloadgeschwindigkeit geben als normalen Benutzern, oder pro Benutzer ein tägliches Gesamtlimit festlegen.  Und so schwer ist das garnicht.

Es wird die Extension FileInfo benötigt, seit PHP 5.3.0 Standard, für ältere Versionen kann sie aus der PECL installiert werden. Man kann die entsprechende Stelle auch umgehen und anders lösen falls man FileInfo nicht nutzen möchte/kann.

Es gibt dafür zwar das PEAR-Paket HTTP_Download, aber das ist veraltet (PHP4-Code) und wirft Notices und E_STRICT Meldungen.

Hier mein Script (auf das wesentliche gekürzt, damit es hier in den Blog passt):

<?
class RatedSender
{
	/**
	 * Send a file as download to the browser (maybe limited in speed)
	 *
	 * @param string $filePath
	 * @param int $rate speedlimit in KB/s
	 * @return void
	 */
	public function send($filePath, $rate = 0)
	{
		// Check if file exists
		if (!is_file($filePath)) {
			throw new Exception('File not found.');
		}

		// get more information about the file
		$filename = basename($filePath);
		$size = filesize($filePath);
		$finfo = finfo_open(FILEINFO_MIME);
		$mimetype = finfo_file($finfo, realpath($filePath));
		finfo_close($finfo);

		// Create file handle
		$fp = fopen($filePath, 'rb');

		$seekStart = 0;
		$seekEnd = $size;

		// Check if only a specific part should be sent
		if(isset($_SERVER['HTTP_RANGE'])) {
			// If so, calculate the range to use
			$range = explode('-', substr($_SERVER['HTTP_RANGE'], 6));

			$seekStart = intval($range[0]);
			if ($range[1] > 0) {
				$seekEnd = intval($range[1]);
			}

			// Seek to the start
			fseek($fp, $seekStart);

			// Set headers incl range info
			header('HTTP/1.1 206 Partial Content');
			header(sprintf('Content-Range: bytes %d-%d/%d', $seekStart, $seekEnd, $size));
		}
		else {
			// Set headers for full file
			header('HTTP/1.1 200 OK');
		}

		// Output some headers
		header('Cache-Control: private');
		header('Content-Type: ' . $mimetype);
		header('Content-Disposition: attachment; filename="' . $filename . '"');
		header('Content-Transfer-Encoding: binary');
		header("Content-Description: File Transfer");
		header('Content-Length: ' . ($seekEnd - $seekStart));
		header('Accept-Ranges: bytes');
		header('Last-Modified: ' . gmdate('D, d M Y H:i:s', filemtime($filePath)) . ' GMT');

		$block = 1024;
		// limit download speed
		if($rate > 0) {
			$block *= $rate;
		}

		// disable timeout before download starts
		set_time_limit(0);

		// Send file until end is reached
		while(!feof($fp))
		{
			$timeStart = microtime(true);
			echo fread($fp, $block);
			flush();
			$wait = (microtime(true) - $timeStart) * 1000000;

			// if speedlimit is defined, make sure to only send specified bytes per second
			if($rate > 0) {
				usleep(1000000 - $wait);
			}
		}

		// Close handle
		fclose($fp);
	}
}

try {
	$ratedSender = new RatedSender();
	$ratedSender->send('data.zip', 3);
} catch (Exception $e) {
	header('HTTP/1.1 404 File Not Found');
	die('Sorry, an error occured.');
} ?>

Das Script unterstützt durch die RANGE Angabe sowohl Resume als auch den parallelen Download einer einzelnen Datei mit mehreren Threads, wie es mit vielen Download-Managern möglich ist. In diesem Beispiel wird also jede Verbindung auf 3 KB/s beschränkt. Möchte man dieses Limit nicht pro Verbindung sondern pro User/IP-Adresse setzen muss man das in PHP errechnen.

Viele Downloadscripte sind unsicher da man z.B. via GET-Parameter den Dateinamen angeben kann, dort sollte man sehr aufpassen dass man nur Dateien zum Download anbietet die auch wirklich herunterladbar sein sollen.

Verbesserungsvorschläge sind natürlich willkommen!

Written by Michael Kliewe

März 4th, 2010 at 9:16 am

19 Responses to 'Dateidownload via PHP mit Speedlimit und Resume'

Subscribe to comments with RSS or TrackBack to 'Dateidownload via PHP mit Speedlimit und Resume'.

  1. Das ist eine super Sache. Nicht, dass ich dannach gesucht hätte, aber nun kenne ich zumindest sowas und evtl. braucht man es ja mal.
    Danke für dieses tolle Beispiel!

    Sascha Presnac

    4 Mrz 10 at 10:14

  2. […] Artikel von PHP Gangsta, setzt zwar die Extension FileInfo voraus, diese ist aber seit PHP 5.3 Standard, für die Zukunft […]

  3. tolles skript, wie man den D/L speed throttlen kann hat mich schon immer interessiert.

    chrisse

    4 Mrz 10 at 11:36

  4. Wenn ich das recht sehe, nutzt Du fileinfo hier nur für den Mimetype?
    Das geht, zwecks Abwärtskompatibilität, auch ohne:
    echo mime_content_type($filename);

    Das flush() ist innerhalb der Schleife zwar logisch korrekt, kann aber bei ungünstigen Parametern krassen Overhead erzeugen.
    Wenn man zig parallele Downloads künstlich lange in der Queue hält, sollte man sich auch seine Webserver Konfiguration nochmal gut anschauen, nicht das irgendwann die Connections auslaufen… aber die Diskussion hat ja nicht direkt mit Deinem Script zu tun 😉

    Wirklich eine sehr, sehr schöne Lösung! Danke das Du das mit uns teilst, ich hab auch schon gleich eine Idee wo ich das verwenden kann.

    Könntest Du vielleicht kurz schreiben, mit welchen Tools Du das Resume bzw. die parallelen Downloads getestet hast? Bin neugierig ob die Tools sich da alle gleich verhalten.

    Kevin

    4 Mrz 10 at 12:53

  5. Für Abwärtskompatibilität gebe ich dir recht, da könnte man auch mime_content_type nehmen. ABER: Wenn man hier guckt
    http://php.net/manual/en/function.mime-content-type.php
    liest man dass die Funktion bereits Deprecated ist und nicht mehr genutzt werden soll, deshalb hab ich FileInfo genutzt. Aber für alte Systeme wo man kein FileInfo installieren kann ist es eine gute Lösung.
    Eine andere mögliche Lösung sind Funktionen, die anhand der Dateiendung einen Mimetype bestimmen. Ist aber auch nur eine Zwischenlösung, da Endungen umbenannt werden können, die ersten Bytes einer Datei aber zuverlässig den Mimetype verraten.

    An die Tools kann ich mich nicht genau erinnern, gibt ja so viele davon, die bekanntesten sind wohl
    http://www.freedownloadmanager.org/
    http://www.flashget.com/index_en.htm
    Ich hatte aber glaub ich auch schonmal ein Firefox-Addon was Resume und paralleles Downloaden einer Datei konnte.

    Michael Kliewe

    4 Mrz 10 at 13:11

  6. @Michael Kliewe: Das Firefox-AddOn heißt DownThemAll und ist hier zu finden: https://addons.mozilla.org/de/firefox/addon/201

    Sascha Presnac

    4 Mrz 10 at 15:06

  7. Kannst Du kurz erklären, warum du upload von Files in DB’s als böse erachtest? Wir hatten schon einige Diskussionen warum ja und warum nein.

    Ralph Meier

    5 Mrz 10 at 10:18

  8. Ist natürlich auch abhängig von den Rahmenbedingungen, aber eine Datenbank sollte wenn möglich klein gehalten werden damit sie in den RAM des Servers passt. Wenn ich nun viele Gigabytes an Daten in die Datenbank lege (als BLOBs natürlich) wird die Datenbank zwangsweise langsamer. Man stelle sich vor, Flickr legt alle Bilder in einer Petabyte-großen Datenbank ab. Oha!
    Backups und Replikation dauern dann auch dementsprechend länger. Man müßte, je nach Einsatzzweck, nicht nur den Dateiinhalt selbst speichern, sondern auch den Dateinamen + Erstellungszeit + Last-Modified-Zeitpunkt. Alles Dinge, die das Dateisystem von Haus aus beherrscht.
    Falls man inkrementelle Backups der Dateien machen möchte (tägliches Backup, nur die geänderten Dateien werden auf Band gespeichert oder so) ist das mit Dateien in der Datenbank komplizierter als einfach z.B. ein rsync des Dateisystems.
    Des weiteren belastet man den häufig eh bereits ausgelasteten Datenbankserver unnötig. Wie wir wissen ist ja die Datenbank oft das Nadelöhr eines größeren Projekts. Wenn man Dateien auf der Festplatte speichert, kann man diese sehr einfach via Lighty/Nginx etc. an den Browser ausliefern, man muss weder den Apache noch die Datenbank damit belasten. Habe mal den Faktor 10 gelesen bei der Auslieferung von Dateien aus einer Datenbank.
    Außerdem muss man im Fall einer Datenbank Dinge wie Resume, partieller Download oder „If-Modified-Since“-Header selbst nachbauen.
    Soweit ich weiß kann man aus BLOBs nicht nur Teile auslesen (also beispielsweise die ersten 100KB oder so), man muss immer das ganze Feld abfragen. Damit wird Resume bzw. partieller Download quasi unmöglich.
    BLOBs in einer Datenbank sind größenbeschränkt (4GB in MySQL, 2GB in Postgresql). Wird man zwar selten erreichen, aber ist eine Grenze die ein modernes Dateisystem nicht hat.
    Falls man sein Projekt bei einem Hoster liegen hat gibt es mitunter auch Größenbeschränkungen der Datenbank auf 100MB oder so.

    Ich überlege gerade auch was passiert mit PHP, wenn ich eine 500MB „Datei“ aus der Datenbank lade und an den Browser ausliefere. Frisst mein PHP-Prozess dann eventuell 500MB RAM, weil ich die Daten ja in einer Variablen halten muss?

    Arg, ist doch nicht so „kurz“ geworden wie du wolltest. Das sind so die Dinge die mir auf Anhieb durch den Kopf schiessen, mag noch viele andere Vor- und Nachteile geben, wäre natürlich gut wenn du/ihr noch welche nennt.

    Michael Kliewe

    5 Mrz 10 at 10:59

  9. Es ist ja so… eine Weile lang hat „man“ ja gehört, dass es cool ist Bilder in der DB als Blob zu speichern. So hat es dann auch unser Chef uns nahegelegt.

    Ahnungslos gingen wir an die Geschichte heran und hatten seither nur Probleme damit.

    2 Hauptprobleme, welche sich für mich herausgestellt haben:
    – Bei einer 8MB Datei (z.B. PDF) braucht der MySQL Server ca. 100MB RAM
    – Wir hatten permanent Probleme mit der Ausgabe als Blob-Stream (ja, wieder einmal der IE6 + IE7). Es mussten verschiedene Varianten ausprogrammiert werden -> HTTP Header

    Am Anfang war ich auch noch der Meinung, dass es eine gute Idee ist, Files in der DB zu speichern (Es sind ja auch Daten). Das Backup ist bereits erledigt und es ist alles schön an einem Ort.
    Inzwischen würde ich aber wieder die File Variante bevorzugen.

    Es wäre aber noch interessant, wenn jemand sagen könnte, was aktuell State of the art ist und warum?

    Ralph Meier

    5 Mrz 10 at 11:10

  10. @Ralph: IMHO sollte man das ganze SOTA Gerede nicht überbewerten. Die beste Variante ist die, mit der ihr a) euch gut auskennt und die b) am besten zur Aufgabe passt.

    Sascha Presnac

    5 Mrz 10 at 11:50

  11. @Sascha: Grundsätzlich stimme ich mit Dir überein, dass man nicht immer jedem Hype nachrennen sollte. Trotzdem muss man neue Wege gehen, wenn man sich weiterentwickeln will. Das heisst man muss auch neue Dinge ausprobieren. State of the art sind für mich Dinge, die sich schon eine Weile bewähren (also nicht zu verwechseln mit einem Hype).

    Ralph Meier

    5 Mrz 10 at 11:56

  12. @Ralph: Okay, hätten wir den begriff definiert und mit dieser Definition gehe ich mit dir konform. Manche verwechseln allerdings Hype und SOTA und von daher bin ich eher vorsichtig, wenn es um diese Begriffe geht.
    Man hat halt immer den alten Konflikt, die Entscheidung zwischen „Never touch a running system“ (was läuft, ist gut) und dem „look over your horizon“-prinzip (neues lernen, besser werden) und muss auch jedesmal schauen, was sich für die Situation besser eignet.

    Sascha Presnac

    5 Mrz 10 at 12:52

  13. nice!
    list($seekStart, $seekEnd) = array_map('intval', explode('-', substr($_SERVER['HTTP_RANGE'], 6), 2));

    Malte

    5 Mrz 10 at 23:12

  14. […] Dateidownload via PHP mit Speedlimit und Resume […]

  15. […] einen wäre da der Artikel “Dateidownload via PHP mit Speedlimit und Resume” auf phpgangsta.de oder die GIT 101 Präsentation von Scott Chacon. Außerdem war ich auf der Suche […]

  16. Nice Script – Thanks for sharing! Allerdings solltest du noch eine kleine if ($wait < 1000000) vor dem usleep ergänzen, für den Fall, dass der flush länger als 1 Sekunde benötigt…

    deluxe.cd

    22 Sep 10 at 12:11

  17. Hi, ich hatte jüngst mit deinem Code ein Problem: HTTPS-Dateidownload mit IE6/7/8. Und zwar fehlte der folgende Header:

    header('Pragma: public');

    Sobald dieser gesetzt ist, können auch IEs die Datei per HTTPS herunterladen.

    Diese Bugs sind Microsoft bekannt:
    http://support.microsoft.com/kb/812935/en-us
    http://support.microsoft.com/kb/323308/en-us

    VG
    Robert

    Robert

    10 Nov 10 at 10:52

  18. […] High School – Engadget German Windows-Netzwerkeinstellungen einfach umschalten | heise Netze Dateidownload via PHP mit Speedlimit und Resume | PHP Gangsta – Der PHP Blog Schutz vor nervigen Spamanrufen Windows XP Pro: Mehr Zugriffe f?r Remote-PCs – PC-WELT […]

  19. Hi,

    hab das Script ein wenig verändert um andere Mimetypes zu unterstützen und habe momentan das Problem, das .mp4 Videos in Chrome 8.0.552.237 nicht funktionieren, in Chrome 10 (Canary Build) jedoch dann wieder laufen. Ist das ein Bug in Chrome, jemand ne Ahnung. Ansonsten läuft das Script ganz wunderbar, danke dafür! Noch eine andere Frage, in wieweit geht es auf die Performance, gibts da Erfahrungen?

    Grüße

    Ronny

    1 Feb 11 at 09:03

Leave a Reply

You can add images to your comment by clicking here.