Hello everyone. I wanted to serve large files with jPlayer while keeping these secure and have done a lot of research on the web on how this is possible. Now that I've successfully transferred over 58 Terabytes of music on my website since its launch, I wanted to share my knowledge with you guys.
The problem
Do you have one of the following problems?
- My streams / downloads get cut off after a while
- My files are exposed to the public! Can I protect them with htaccess / PHP / <insert other methods>?
Then this guide will help you!
Requirements
To implement this solution, you will need the following:
- A server that supports the X-Sendfile module, an Apache mod that handles file transfers for you
- Access to the file system that holds your website. More specifically, you need to be able to create a folder outside your domain folder. This will hold your files out of reach of the public.
- Some PHP knowledge. Most of the things explained here contain comments that help you comprehend the code, but knowing some PHP will drastically help you understand what the thing is doing.
The solution
The solution is a co-operation of the following elements:
- A PHP script that finds the requested file in your hidden folder
- Using X-Sendfile HTTP Headers that push the file to the public instead of PHP functions like fread()
Lets start with creating a folder outside your domain folder. You can best achieve this by using an FTP program (something you are probably already using to upload content to your website). I highly recommend FileZilla in case you don't have one yet. It's free and easy to use. You can also achieve creating a folder by using web-based FTP, provided that your hosting company has a control panel that allows you to do this.
Locate your domain's folder, and navigate up one level. This is usually your root directory, but it depends on your hosting provider. It's possible that your domain sits inside a "html-public" or "wwwroot" folder, or something similarly named. It depends on the server setup, and it could indicate that you are not on an Apache web server. If this is the case, you may want to double check what kind of server your host is running. Chances are that it's not an Apache server, which could mean that it does not have the X-Sendfile mod to begin with. You will need this to serve large files.
Create a new folder here, name it something clever like "audio" or "hidden", depending on your needs. It should now look something like this:
These red folders are located outside the domain folder, which contains your website.
Inside your hidden folder, you can arranges your files anyway you need; you do not have to keep a flat file structure!
Now that your hidden folder is created, we can continue with creating the script that locates the files in your hidden folder.
A basic script would be:
if(!isset($_REQUEST['file']) || empty($_REQUEST['file']))
// invalid argument, abort script
header("HTTP/1.0 400 Bad Request");
$file_requested = $_GET['file'];
$file_name = strip_tags($file_requested);
$file_path = '/home/tjoonz/audio/techno/';
$file_full = $file_path.$file_name;
if (file_exists($file_full)) // test if requested mixtape exists
header("X-SENDFILE: ".$file_full);
header("Content-Type: application/octet-stream");
header("Content-Disposition: attachment; filename=".basename($file_full));
header("Content-Length: ".filesize($file_full));
header("Pragma: public");
header("Cache-Control: public, must-revalidate, post-check=0, pre-check=0");
header("Content-Description: File Transfer");
header("Content-Transfer-Encoding: binary");
else // couldn't find that mixtape
header('HTTP/1.0 404 Not Found');
die();
Let's break down the above script:
- First, check if the script was passed a file argument, and if so, it must not be empty. If this check is failed, return a HTTP Header with code 400 Bad Request. Finally, die(), aborting execution of the rest of the script.
- If we make it this far (step 1 succeeds), continue with script by setting a couple of variables. A variable is a piece of memory with a name. We fill the memory with some values so that we don't have to type the whole values every time we want to use it. Save the value of our file argument in a variable called file_requested.
- While we are setting variables, we essentially 'sanitize' it. It is very important to sanitize your user-input, because you prevent unauthorized users accessing your file system. To clarify, by blindly accepting any user input, malicious users can enter requests to files in folders that you do not want them to access. For example, if they requested the file with a leading slash (/), they could jump to the root of your hosting, and from there access any other files. We sanitize our file by prefixing it with an absolute path as seen from the root. This eliminates malicious users from attempting to trick the script to access a different folder.
- Now that we have reconstructed the full absolute path to the file, we need to check if this is actually valid. There are multiple ways to check this. I chose file_exists(), but you could also use is_file() for example, depending on your needs. When this check fails, it jumps down to the else statement, which returns HTTP Header 404 File Not Found. If the check succeeds, the script continues.
- The final step of the script is to initiate the file transfer. According to the X-Sendfile documentation, it is not necessary to send various other headers, like Content-Length. My script may feature some over-the-top headers, but I've had some experiences with certain browsers not finding the Content-Length (which is needed to tell jPlayer what the total duration of a file is). I am sure that the community of jPlayer here on Google Groups will point out alternatives of doing essentially the same thing. I am also still open for suggestions and improvement. Please note that aside from browser support, it may also be your server's configuration of the X-Sendfile mod that may require these additional headers to be set up. Just remember that for the basic functionality of sending the file, you only need the first header: header("X-SENDFILE: ".$file_full);
The final step to this solution is to ensure that X-Sendfile is activated on our domain. You will have to place a .htaccess file in the root of your website folder. It can contain anything, but at the first 2 lines you have to enter the following code:
XSendFile On
XSendFileAllowAbove On
These statements enable X-Sendfile, and allows it to travel to folders higher than your website's folder. This is essential because that is where our hidden folder is located.
Also PLEASE NOTE that if you don't correctly type your htaccess file OR if your server does not have X-Sendfile OR if X-Sendfile is not enabled for your domain, the above lines in the htaccess file will cause your entire website to throw a 500 Internal Server Error. Don't fear, remove the lines and it's gone. Again, ensure you are able to use X-Sendfile FIRST!
But, why must I use X-Sendfile? Can't I just use PHP to send the file?
You can use fread(), or other PHP solutions (like chunked transfers and such). Let me elaborate why PHP can be counterproductive for you, depending on your needs.
First of all, depending on your server's configuration, PHP scripts can execute only for a certain amount of time. Your server may be configured to kill any running PHP process that has not finished after 30 seconds. Most shared hosting environments do not allow you to change the timeout for this behavior. It's possible that even if you use PHP commands to temporarily disable this timeout, your shared hosting provider is guarded against it. Now, this may not be a problem if you are serving files that are transferred within 30 seconds (or whatever your server's timeout is), but as I mentioned at the beginning, this is problematic when you want to send large files. It can also be problematic for small files, if your users have a slow connection. That's why the PHP solution may work for you, when you are testing your scripts, but it may not work for the majority of your users. I learned this the hard way, having unsatisfied visitors for while!!
The reason X-Sendfile is so damn good to use is because it uses a much more logical way to interact with the client for sending files. I will not dig into the technicalities of that, but what is important to know is that once the X-Sendfile HTTP Header is output by your PHP script, that's it! Your script finishes, so the PHP process ends on the server. Meanwhile X-Sendfile instructed the server to fully handle the file transfer without the need of any PHP interaction!
Getting X-Sendfile to work!
As said before, it depends on your server. It can be hard to determine if you are on a shared server, it's a good idea to ask your hosting's support for it.
For example, my websites using this script run on a Dreamhost shared server. It's good to know that while X-Sendfile is available here on shared hosting, a Dreamhost employee has to specifically enable it for your domain(s). Once it is enabled for one domain, it's not automatically enabled for the other domain. At Dreamhost, I can simply create a support ticket asking for X-Sendfile activation.
If you have the privilege and money to run on a private server, you will need to install X-Sendfile yourself. As I have no experience with this, I cannot help you with that. Sorry!
Example of my full download script, which also counts the number of plays & downloads
You are free to (ab)use the below script to your needs. I do not care for credit, I just hope you learn something new =)
The following script is used in a WordPress environment. The link to start a download, or play a file in jPlayer looks as follows:
<a class="jplayer-playnow" href="/<?php echo $category[0]->category_nicename; ?>.php?id=<?php the_ID(); ?>&file=<?php echo the_slug(); ?>.mp3">Play it!</a>
For visitors on the front-end, it looks like this (for a post in the Techno category in WordPress):
<a class="jplayer-playnow" href="/techno.php?id=3214&file=larry-larsson-underground-vinyl-sessions-episode-1.mp3">Play it!</a>
The script below then uses the id and file arguments to increment the counter and serve the audio to jPlayer (or download, depending what the user clicked on the site):
<?php // Tjoonz.com download script, courtesy of Marc Dingena
if(!isset($_REQUEST['id']) || empty($_REQUEST['id']) || !isset($_REQUEST['file']) || empty($_REQUEST['file']))
// invalid argument(s), abort script
header("HTTP/1.0 400 Bad Request");
$user_ip = $_SERVER['REMOTE_ADDR'];
$file_requested = $_GET['file'];
$file_name = strip_tags($file_requested);
$file_path = '/home/tjoonz/audio/techno/';
// test if cookie 'downloadsAllowed' is set
// this prevents hotlinking on other domains for users who haven't visited Tjoonz.com yet
if ($_COOKIE["downloadsAllowed"] == "tj00nz")
$file_full = $file_path.$file_name;
if (file_exists($file_full)) // test if requested mixtape exists
// establish connection to database for Play Counter test
$password="YOUR_PASSWORD";
$database="YOUR_DATABASE";
mysql_connect($host,$user,$password);
mysql_select_db($database) or die(mysql_error());
// set timerange to 2 hours ago (right now minus 7200 miliseconds)
$timerange = $current_time - 7200;
// get all records from 'playcount_lock' table
$locks = mysql_query("SELECT * FROM playcount_lock WHERE request_time > $timerange") or die(mysql_error());
// test if current IP address has already accessed this mixtape in the last 2 hours
while ($lock = mysql_fetch_object($locks))
// set variables with data from record
$lock_ip = $lock->ip_address;
$lock_id = $lock->post_id;
$lock_time = $lock->request_time;
// compare record data with current user data
if($lock_ip == $user_ip && $lock_id == $post_id && ($lock_time + 7200) >= $current_time)
// match found, break out of while loop and continue with script
if (!$locked) // if playcounter is not locked, update the database
// add a lock entry for the next two hours
mysql_query("INSERT INTO `playcount_lock` (`request_time`, `post_id`, `ip_address`) VALUES ('$current_time', '$post_id', '$user_ip')") or die(mysql_error());
// update the play counter for this mixtape
$post_meta = mysql_query("SELECT meta_value FROM `wp_postmeta` WHERE `meta_key` = '_played' AND `post_id` = ".$post_id);
$count = @mysql_result($post_meta, 0);
// if no plays are present, insert the metadata into table
mysql_query("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (".$post_id.",'_played',1)");
// otherwise increase the existing number of plays by 1
$newCount = mysql_result($post_meta, 0) + 1;
mysql_query("UPDATE `wp_postmeta` SET meta_value = ".$newCount." WHERE `meta_key` = '_played' AND `post_id` = ".$post_id);
// we're done with the database, kill the connection
// finally, send the file
header("X-SENDFILE: ".$file_full);
header("Content-Type: application/octet-stream");
header("Content-Disposition: attachment; filename=".basename($file_full));
header("Content-Length: ".filesize($file_full));
header("Pragma: public");
header("Cache-Control: public, must-revalidate, post-check=0, pre-check=0");
header("Content-Description: File Transfer");
header("Content-Transfer-Encoding: binary");
else // couldn't find that mixtape
header('HTTP/1.0 404 Not Found');
die();
else // cookie 'downloadsAllowed' not set, redirecting user to mixtape page
$file_redirection = substr($file_name,0, -4);
Closing words
I hope this little guide was of good use to you. It should answer a couple of questions that most users seem to have regarding serving large files to jPlayer with PHP.
I'm open to suggestions and improvement. Please comment =)
Thank you.