Mit CodeIgniter und cURL den Download-Speed drosseln

Viele Webseiten, insbesondere Download-Portale,  sind bekannt dafür, dass sie die Download-Geschwindigkeit von Dateien (zum Teil sehr stark) drosseln. Meistens beruht darauf deren Geschäftsmodell, indem man die volle Download-Geschwindigkeit nur dann bekommt, wenn man dafür bezahlt.

In diesem Tutorial möchte ich euch zeigen, was dahinter steckt und wie diese Anbieter hier softwareseitig tricksen. Dazu zeige ich hier, wie man eine solche Download-Drosselung in ein CodeIgniter-Projekt integriert.

 

cURL als Kern der Anwendung

Bei cURL handelt es sich um eine weit verbreitete Kommandozeilenanwendung, welche speziell für das Übertragen von Daten innerhalb von Computernetzwerken ausgelegt ist. Der Vorteil liegt in der einfachen Verwendung, denn die Programmbibliothek wurde für so ziemlich alle Betriebssysteme portiert. Außerdem hat sie einen immensen Funktionsumfang.

Im Vergleich zum älteren Vorgänger “wget” beherrscht cURL nicht nur das Herunter-, sondern auch das Hochladen von Dateien. Zahlreiche Parameter bieten dafür entsprechend viele Optionen, um so gut wie jedes Szenario abdecken zu können.

 

Setup mit CodeIgniter

Nun kommen wir zum wesentlichen Teil dieses Tutorials – dem Einbau einer Speed-Drosselung in ein CodeIgniter-Projekt.

 

Schritt 1: Basissystem vorbereiten

Um mit unserem Projekt starten zu können, erstellen wir zunächst die Basis, auf der wir anschließend aufbauen können. Dazu laden wir uns von der CodeIgniter-Homepage die aktuellste Version des Frameworks herunter. Dies wäre in diesem Fall Version 3.1.5.

Nun kopieren wir alle Dateien am besten in einen Unterordner „ci-downloadspeed“ auf den Webserver (z.B. XAMPP, LAMP, etc.) und rufen dessen Rootverzeichnis im Browser auf, z.B. http://localhost/ci-downloadspeed/. Wenn alle Anforderungen des Frameworks erfüllt sind, sollte hier nun die Willkommensseite von CodeIgniter sichtbar sein.

Damit die Speaking-URLs auch korrekt aufgerufen werden können, legen wir uns eine .htaccess mit folgendem Inhalt an:

RewriteEngine on

RewriteRule ^(assets|downloads)($|/) - [L]
RewriteRule .* index.php

Im Verzeichnis “assets” werden wir alle Ressourcen ablegen, die wir für die eigentliche Applikation brauchen werden (CSS und JS-Dateien), im Verzeichnis “downloads” kommen später unsere Beispiel-Download-Dateien hinein (Infos dazu befinden sich im Abschnitt “Fazit”).

In der Autoloader-Konfiguration unter /application/config/autoload.php fügen wir anschließend den URL-Helper hinzu. Damit können wir einerseits im Frontend, und anderseits diesmal auch innerhalb der Applikation selbst die base_url()-Funktion nutzen.

$autoload['helper'] = array('url');

In der Routenkonfiguration unter /application/config/routes.php ändern wir als danach den Standard-Controller von „welcome“ auf „home“, da wir möchten, dass standardmäßig unsere Speed-Drosselungs-Applikation aufgerufen wird.

$route['default_controller'] = 'home';
$route['404_override'] = '';
$route['translate_uri_dashes'] = FALSE;

 

Schritt 2: Grundlayout definieren

Dazu erstellen wir unter /application/controller eine Home.php (Groß-/Kleinschreibung beachten!).

In dieser legen wir als nächstes das Grundgerüst unserer Applikation an – dies wären zwei Actions: eine für die Index-Seite und eine für die gedrosselten Datei-Downloads. Letztere bekommt noch Parameter für den Typ des Downloads (in unserem Beispiel zur Unterscheidung zwischen einer kleinen und einer großen Datei), sowie für den Download-Speed selbst (in KB/s).

<?php
defined('BASEPATH') OR exit('No direct script access allowed');

class Home extends CI_Controller
{


	public function index()
	{
		
	}


	public function download($type = 1, $speed_kbs = 64)
	{
		
	}

}

Die Index-Action ist hier sehr rudimentär, da sie alleine keinerlei Funktionalität besitzt. Sie dient lediglich zur Anzeige der Hauptseite unserer Applikation:

public function index()
{
	// load view
	$this->load->view('home');
}

Kommen wir nun zum HTML-Layout der verlinkten View.

<?php defined('BASEPATH') OR exit('No direct script access allowed'); ?>
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/html">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="">
    <meta name="author" content="">

    <title>CI Download-Speed</title>

    <!-- Latest compiled and minified CSS -->
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css"
          integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">

    <!-- Custom CSS for basic styling -->
    <link rel="stylesheet" href="<?php echo base_url(); ?>assets/css/style.css"/>

    <!-- Optional CSS for individual theming (powered by Bootswatch - https://bootswatch.com/) -->
    <link rel="stylesheet" href="<?php echo base_url(); ?>assets/css/bootstrap_flatly.min.css"/>

    <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
    <!--[if lt IE 9]>
    <script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
    <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
    <![endif]-->
</head>
<body>

<nav class="navbar navbar-default navbar-fixed-top">
    <div class="container">
        <div class="navbar-header">
            <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar"
                    aria-expanded="false" aria-controls="navbar">
                <span class="sr-only">Toggle navigation</span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
            </button>
            <a class="navbar-brand" href="#">CodeIgniter Download-Speed</a>
        </div>
        <div id="navbar" class="collapse navbar-collapse">
            <ul class="nav navbar-nav">
                <li class="active"><a href="<?php echo base_url(); ?>">Home</a></li>
                <li><a href="#about">About</a></li>
                <li><a href="#contact">Contact</a></li>
            </ul>
        </div>
    </div>
</nav>

<div class="container">

    <div class="intro">
        <h1>Choose your download speed!</h1>
        <p class="lead">
            Lorem ipsum dolor sit amet, consetetur sadipscing elitr, <br/>
            sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, <br/>
            sed diam voluptua.
        </p>

        Here will be our core application...
        


    </div>
    
</div>


<!-- Bootstrap core JavaScript
================================================== -->
<!-- Placed at the end of the document so the pages load faster -->
<script type="text/javascript" src="https://code.jquery.com/jquery-3.2.1.min.js"
        integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4=" crossorigin="anonymous"></script>
<script type="text/javascript" src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"
        integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa"
        crossorigin="anonymous"></script>
<script type="text/javascript" src="<?php echo base_url(); ?>assets/js/app.js"></script>
</body>
</html>

Wir haben hier also mal das Basis-Layout mit einer Navigationsleiste sowie einem einfachen Textblock mit einer Überschrift angelegt.

Gleich darunter (wo aktuell noch “Here will be…” steht) folgt nun das Markup unserer Anwendung: ein Speedmeter, das wir auf Basis von “HTML5 Canvas Speedometer” aufbauen werden. Dazu gibt’s zwei Download-Buttons (einen für eine kleinere, und einen zweiten für eine größere Datei):

<canvas class="canvas" id="myCanvas" width="600" height="400">
    Your browser does not support the HTML5 canvas tag.
</canvas>

<div class="row">
    <div class="col-md-6 col-md-offset-3">
        <div id="slider">
            <p class="lead">max. <span id="selectedSpeed"></span> kB/s</p>

            <input style="width:100%" id="slide" type="range" min="0" max="1750" step="50" value="100"/>

        </div>
    </div>
</div>


<p>&nbsp;</p>

<p>
    <a href="#" class="btn btn-primary btn-lg start-download"
       data-url="<?php echo base_url(); ?>home/download/1/###speed###">
        <span class="glyphicon glyphicon-download-alt" aria-hidden="true"></span> Download (100 MB)
    </a>

    &nbsp;

    <a href="#" class="btn btn-primary btn-lg start-download"
       data-url="<?php echo base_url(); ?>home/download/2/###speed###">
        <span class="glyphicon glyphicon-download-alt" aria-hidden="true"></span> Download (1 GB)
    </a>
</p>

Beim verlinkten bootstrap_flatly.min.css handelt es sich um das kostenlose Bootstrap-Theme “Flatly” von Bootswatch.

Dieses CSS-Theme ist optional und kann daher also gerne auch weggelassen werden.

In der style.css definieren wir ein paar Basis-Styles:

body {
	padding-top: 50px;
}
.intro {
	padding: 40px 15px;
	text-align: center;
}

.intro h1 {
	margin-bottom: 40px;
}

In die verlinkte app.js kommt die eigentliche Logik für das Frontend unserer Applikation hinein.

// Based on:  http://www.knowstack.com/html5-canvas-speedometer/

$(document).ready(function(){

    var draw = function(speed)
    {
        var  canvas = document.getElementById("myCanvas");
        var  context = canvas.getContext("2d");
        context.clearRect(0,0,canvas.width, canvas.height);
        var centerX = canvas.width / 2;
        var centerY = (canvas.height / 5) * 4;
        var radius = canvas.width / 2 - 20;

        context.beginPath();
        context.arc(centerX, centerY, radius, Math.PI*0.10, Math.PI*-1.1, true);

        var gradience = context.createRadialGradient(centerX, centerY, radius-radius/2, centerX, centerY, radius-radius/8);
        gradience.addColorStop(0, '#ffffff');
        gradience.addColorStop(1, '#18bc9c');

        context.fillStyle = gradience;
        context.fill();
        context.closePath();
        context.restore();

        context.beginPath();
        context.strokeStyle = '#ffffff';
        context.translate(centerX,centerY);
        var increment = 50;
        context.font="15px Helvetica";
        for (var i=-18; i<=18; i++)
        {
            angle = Math.PI/30*i;
            sineAngle = Math.sin(angle);
            cosAngle = -Math.cos(angle);

            if (i % 5 == 0) {
                context.lineWidth = 8;
                iPointX = sineAngle *(radius -radius/4);
                iPointY = cosAngle *(radius -radius/4);
                oPointX = sineAngle *(radius -radius/7);
                oPointY = cosAngle *(radius -radius/7);

                wPointX = sineAngle *(radius -radius/2.5);
                wPointY = cosAngle *(radius -radius/2.5);
                context.fillText((i+18)*increment,wPointX-2,wPointY+4);
            }
            else
            {
                context.lineWidth = 2;
                iPointX = sineAngle *(radius -radius/5.5);
                iPointY = cosAngle *(radius -radius/5.5);
                oPointX = sineAngle *(radius -radius/7);
                oPointY = cosAngle *(radius -radius/7);
            }
            context.beginPath();
            context.moveTo(iPointX,iPointY);
            context.lineTo(oPointX,oPointY);
            context.stroke();
            context.closePath();

        }

        var numOfSegments = speed/increment;
        numOfSegments = numOfSegments -18;
        angle = Math.PI/30*numOfSegments;
        sineAngle = Math.sin(angle);
        cosAngle = -Math.cos(angle);
        pointX = sineAngle *(3/4*radius);
        pointY = cosAngle *(3/4*radius);

        context.beginPath();
        context.strokeStyle = '#2c3e50';
        context.arc(0, 0, 19, 0, 2*Math.PI, true);
        context.fill();
        context.closePath();

        context.beginPath();
        context.lineWidth=6;

        context.moveTo(0,0);
        context.lineTo(pointX,pointY);

        context.stroke();
        context.closePath();
        context.restore();
        context.translate(-centerX,-centerY);
    };

    
    // TODO: add listener for range-field and function for setting the selected speed

});

Damit wäre das grafische Canvas-Rendering des Speedmeters erledigt. Nun erweitern wir diese Datei um unsere eigentlichen Funktionen (Wichtig: unbedingt innerhalb des “document.ready()”-Blocks, dort wo aktuell noch “TODO: add listener…” steht, schreiben!). Dort befindet sich u.a. die zentrale Funktion zum Setzen des ausgewählten Download-Speeds (“setSpeed()“) und eines Listeners, der auf das Range-Eingabefeld reagiert. In weiterer Folge überträgt der Listener den ausgewählten Wert ins Speedmeter, und passt unsere beiden Download-Links dynamisch an.

var setSpeed = function(speed)
{
    // prevent a speed of zero
    if( speed == 0 ){
        speed = 1;
    }

    // draw speedmeter graphic
    draw(speed);

    // write text under speedmeter graphic
    $('#selectedSpeed').text(speed);

    // set individual download url with dynamically speed assignment
    $('a.start-download').each(function(i, v){
        var url = $(v).data('url');
        url = url.replace('###speed###', speed);

        $(v).attr('href', url);
    });
};


// set inital speed
setSpeed(100);


// check for change of speed value while dragging
$(document).on('input change', '#slide', function() {
    setSpeed( $(this).val() );
});

Weil der Listener die beiden Download-Links dynamisch anpasst, haben wir vorhin das Linkziel der beiden Download-Buttons nicht direkt, sondern nur über ein data-Attribut mittels einem Platzhalter für den eigentlichen Speed gesetzt. So können wir dieses Attribut als Vorlage nutzen, und das Linkziel immer wieder dynamisch neu setzen. Initial setzen wir den Download-Speed auf 100 KB/s. Dies ist die Standardeinstellung, die der Benutzer bekommt, wenn er unsere Applikation öffnet.

 

Schritt 3: Drosselung einbauen

Für den Einbau der Speed-Drosselung benötigen wir nun unsere Download-Action, deren Struktur wir zuvor angelegt haben.

public function download($type = 1, $speed_kbs = 64)
{
	// detect, which example file should be used
	if ($type == 2) {
		$filename = '1000mb.bin';
	} else {
		$filename = '100mb.bin';
	}

	// build up public URL for file (for public access via cURL)
	$public_filename = base_url() . 'downloads/' . $filename;

	// build up internal URL for file (for internal access, like filesize())
	$local_filename = FCPATH . 'downloads/' . $filename;


	// get speed in Bytes/s
	$speed = $speed_kbs * 1000;

	// prepare headers for forcing file download
	header('Content-Type: application/octet-stream');
	header('Content-Length: ' . filesize($local_filename));
	header('Content-Disposition: attachment; filename="' . basename($filename) . '"');


	// TODO: get file and provide it as streamed download (with speed limitation)
}

In dieser wird (in unserem Beispiel) zunächst überprüft, ob die große oder die kleine Beispiel-Datei geladen werden soll. Sobald dies geschehen ist, bauen wir uns die öffentliche URL (mit Domain oder IP, je nach Konfiguration) und die interne URL (absoluter Pfad am Server) zusammen. Gleichzeitig berechnen wir uns unseren Download-Speed, indem wir die übergebenen KB/s in Bytes/s umrechnen (mit dem Faktor 1000).

Anschließend definieren wir uns die PHP-Header für einen Dateidownload. Dazu verwenden wir als “Content-Type” einfach “application/octet-stream“, sodass der Download immer erzwungen wird, und der Browser nicht versucht, die Datei selbst zu öffnen bzw. anzuzeigen. Des Weiteren folgen nun (optionale) Angaben zur Dateigröße, damit der Browser den Fortschritt des laufenden Downloads kennt und anzeigen kann. Am Ende kommt noch der Dateiname selbst dazu.

Nun folgt der eigentliche Download. Dafür nutzen wir cURL und setzen hier dessen Parameter wie folgt:

// init cURL
$ch = curl_init();

// read file from public URL
curl_setopt($ch, CURLOPT_URL, $public_filename);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 500);

// limit download speed (in bytes per second)
curl_setopt($ch, CURLOPT_MAX_RECV_SPEED_LARGE, $speed);

// stream data in realtime, instead of locally download before finally delivery
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($curl, $data) {
	echo $data;
	return strlen($data);
});

// perform "cURL-query"
curl_exec($ch);

// close cURL session
curl_close($ch);

Im Grunde handelt es sich hier großteils um Standard-Parameter, bis auf zwei Ausnahmen: CURLOPT_MAX_RECV_SPEED_LARGE und CURLOPT_WRITEFUNCTION.

Die Option CURLOPT_MAX_RECV_SPEED_LARGE ermöglicht es uns, den maximalen Download-Speed festzulegen, mit welchem die cURL-Bibliothek die Daten liest. Die Angabe hier erfolgt in Bytes/s, daher unsere vorherige Umrechnung. Wird hier der Wert “0” angegeben, erfolgt keine Drosselung, und der Download wird mit unbegrenzter Geschwindigkeit durchgführt. Die tatsächliche Geschwindigkeit ist dann nur mehr abhängig von den externen Faktionen, wie z.B. Internetanbindung selbst.

Damit die von cURL gelesenen Daten auch unmittelbar an den Benutzer gestreamt, und nicht minutenlang (oder gar Stunden! – je nach Speed) zuerst lokal auf den Server geladen wird , ehe sie ausgeliefert werden, geben wir mit Hife von CURLOPT_WRITEFUNCTION unseren eigenen Output-Handler an. Dieser sorgt dafür, dass der empfangene Inhalt 1:1 und in Echtzeit an den Benutzer weitergeleitet wird.

Zusammengefasst sieht unsere Download-Action nun also wie folgt aus:

public function download($type = 1, $speed_kbs = 64)
{
	// detect, which example file should be used
	if ($type == 2) {
		$filename = '1000mb.bin';
	} else {
		$filename = '100mb.bin';
	}

	// build up public URL for file (for public access via cURL)
	$public_filename = base_url() . 'downloads/' . $filename;

	// build up internal URL for file (for internal access, like filesize())
	$local_filename = FCPATH . 'downloads/' . $filename;


	// get speed in KB/s
	$speed = $speed_kbs * 1000;

	// prepare headers for forcing file download
	header('Content-Type: application/octet-stream');
	header('Content-Length: ' . filesize($local_filename));
	header('Content-Disposition: attachment; filename="' . basename($filename) . '"');


	// init cURL
	$ch = curl_init();

	// read file from public URL
	curl_setopt($ch, CURLOPT_URL, $public_filename);
	curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
	curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 500);

	// limit download speed (in bytes per second)
	curl_setopt($ch, CURLOPT_MAX_RECV_SPEED_LARGE, $speed);

	// stream data in realtime, instead of locally download before finally delivery
	curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($curl, $data) {
		echo $data;
		return strlen($data);
	});

	// perform "cURL-query"
	curl_exec($ch);

	// close cURL session
	curl_close($ch);
}

 

Fazit

Eine Speed-Drosselung für Dateidownloads lässt sich mit Hilfe der cURL-Bibliothek sehr leicht und schnell realisieren. Es bedarf hier lediglich der korrekten Konfiguration von cURL.

Aktuell wären in unserem Beispiel-Projekt wäre der Zugriff aud die Downloads auch direkt über deren öffentliche URL möglich. Dies ist für den internen Download via cURL notwendig, da cURL hier mehr oder weniger nur als Proxy fungiert. In der Praxis sollte die direkte Zugriffsmöglichkeit jedoch unterbunden werden. Unter der Annahme, dass der Server eine statische IP-Adresse besitzt, kann man z.B. in den “downloads“-Ordner eine .htaccess legen, die den Zugriff nur über die Server-IP zulässt (alternativ auch über die vHost-Config).

Hier ein Beispiel für eine solche .htaccess-Datei (188.65.77.77 wäre hier die Server-IP, auf welchem die Applikation läuft):

Deny from all
Allow from 188.65.77.77

Somit wäre gewährleistet, dass die Applikation zum Zwecke des internen Downloads auf die öffentliche URL zugreifen darf, alle anderen Benutzer jedoch nicht. Auch wäre möglich, dass die Dateien von einem Remote-Server geladen werden und die Meta-Information (wie z.B. Dateigröße) aus einer Datenbank bezogen wird.

Die beiden verlinkten Dummy-Download-Dateien können u.a. über die Anexia Network Map oder alternativ direkt über diese Links bezogen werden: 100MB und 1GB (via dem DATASIX-Rechenzentrum).

In diesem Tutorial haben wir mit dem CodeIgniter-Framework gearbeitet, weil es sehr leicht verständlich ist und auch fast keine Einarbeitungszeit erfordert. Im Prinzip kann hierfür aber jedes beliebige Framework eignesetzt werden, wie z.B. auch Laravel oder Zend.