1

I've set up some code to download zip files that exist in a folder above the web root. The download will be triggered from a user account page within WordPress. They don't need to be bank-level secure, but I'd like to prevent direct file access from outside the site and make them accessible only for users with the correct permission levels, and only from the appropriate user's account page. The site is entirely https.

The folder where the zip files reside is protected via htaccess.

Each user that's assigned to a specific user role will see a download link on their "Account" page:

if(current_user_can('download_these_files')){
    $SESSION['check'] = 'HVUKfb0IG1HIzHxJj5fZ';
    ?>
        <form class="user-file-download-form" action="/download.php" method="POST">
            <input type="submit" name="submit" value="Download File">
        </form>
    <?php
}

This form submits to download.php, which sits in the web root and includes some code that I've pieced together with help from Google.

session_start();
if( isset( $_POST['submit'] ) ){
    $check = $_SESSION['check'];
    if( $check === 'HVUKfb0IG1HIzHxJj5fZ' ){
        $file = /path/to/file/above/root.zip;
        header('Content-Description: File Transfer');
        header('Content-Type: application/zip');
        header('Content-Disposition: attachment; filename=' . basename($file));
        header('Content-Transfer-Encoding: binary');
        header('Expires: 0');
        header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
        header('Pragma: public');
        header('Content-Length: ' . filesize($file));
        ob_clean();
        flush();
        readfile( $file );
        exit;
    }else{
        header( 'Location: https://example.com/404page/' );
}else{
    header( 'Location: https://example.com/404page/' );
}

This works perfectly. But I can't help but wonder if I should be doing something differently. I was hoping to get input on whether or not this implementation is production-ready, or if I'm missing something important as this is the first time I'm attempting something like this.

Thank you.

2

What you have there is production ready. However, there is room for some minor improvements, so I will point those out for you. Also see my notes below regarding X-Sendfile and X-Accel-Redirect.


Replace these lines:

ob_clean();
flush();

with the following:

while (@ob_end_clean());

The point is, if there is something already in the output buffer, you don't want to flush it out, you only want to clean it. If you flush it, you'll be prefixing your downloadable file contents with the output buffer contents, which would only serve to corrupt the downloadable file. See: http://php.net/manual/en/function.ob-end-clean.php


After this line:

$file = /path/to/file/above/root.zip;

Add the following to ensure GZIP compression at the server-level is turned off. This might not make a difference at your current web host, but move the site elsewhere and without these lines you could see the script break badly.

@ini_set('zlib.output_compression', 0);
if(function_exists('apache_setenv')) @apache_setenv('no-gzip', '1');
header('Content-Encoding: none');

Caution: Be wary of using this PHP-driven file download technique on larger files (e.g., over 20MB in size). Why? Two reasons:

  • PHP has an internal memory limit. If readfile() exceeds that limit when reading the file into memory and serving it out to a visitor, your script will fail.

  • In addition, PHP scripts also have a time limit. If a visitor on a very slow connection takes a long time to download a larger file, the script will timeout and the user will experience a failed download attempt, or receive a partial/corrupted file.


Caution: Also be aware that PHP-driven file downloads using the readfile() technique do not support resumable byte ranges. So pausing the download, or the download being interrupted in some way, doesn't leave the user with an option to resume. They will need to start the download all over again. It is possible to support Range requests (resume) in PHP, but that is tedious.


In the long-term, my suggestion is that you start looking at a much more effective way of serving protected files, referred to as X-Sendfile in Apache, and X-Accel-Redirect in Nginx.

X-Sendfile and X-Accel-Redirect both work on the same underlying concept. Instead of asking a scripting language like PHP to pull a file into memory, simply tell the web server to do an internal redirect and serve the contents of an otherwise protected file. In short, you can do away with much of the above, and reduce the solution down to just header('X-Accel-Redirect: ...').

-1

Instead of using a form just use a link like ..../download.php?file=abc.zip. Check the user credentials at downoad.php and you save both the need for the session and form handling.

  • I'll be checking user credentials at the "Account" page anyway, because only certain user groups will see a link. The other problem I have is that if I put download.php within the WordPress folders and try to use WP functions on the page, the header redirects for the download don't work properly. I was playing around with template_redirect and template_include as well. It's very possible that this is a shortcoming in my knowledge. The only way I could get the header redirects working was to put that file in the web root, outside of the template structure. – Rob Oct 6 '15 at 14:34

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.